Compare commits

..
Author SHA1 Message Date
be-coder-07 e91c8c06a5 fix(#1051): track canonical estate registry
ci/woodpecker/pr/ci Pipeline failed
2026-08-05 23:32:23 -05:00
be-coder-07 a50b5a6b4d fix(mosaic): enforce private brain checkout
ci/woodpecker/pr/ci Pipeline failed
2026-08-05 17:19:51 -05:00
be-coder-07 22cedbb506 fix(mosaic): reject unapproved brain skeleton state 2026-08-05 17:19:51 -05:00
be-coder-07 bad53564ed fix(mosaic): preserve measurable brain git state 2026-08-05 17:19:50 -05:00
be-coder-07 518c4185ee test(mosaic): control brain config owner binding 2026-08-05 17:19:50 -05:00
be-coder-07 877473c244 fix(mosaic): bind brain commits to approved blobs 2026-08-05 17:19:50 -05:00
be-coder-07 f0555016f4 fix(mosaic): isolate brain publication index 2026-08-05 17:19:50 -05:00
be-coder-07 fb7087b3eb fix(mosaic): quarantine unscanned brain state 2026-08-05 17:19:50 -05:00
be-coder-07 836aab1ab5 fix(mosaic): bound brain migration and owner lookups 2026-08-05 17:19:50 -05:00
be-coder-07 656fa9ceb7 fix(mosaic): fail closed on unsafe brain cleanup and config 2026-08-05 17:19:50 -05:00
be-coder-07 6afb3cb5b3 fix(mosaic): hold brain migration destinations by descriptor 2026-08-05 17:19:50 -05:00
be-coder-07 351cb67cec fix(mosaic): exclude nested secret migration paths 2026-08-05 17:19:50 -05:00
be-coder-07 fcfc1b08f9 fix(mosaic): harden brain migration snapshots 2026-08-05 17:19:50 -05:00
be-coder-07 2451c2f21a feat(mosaic): provision per-estate durable brain 2026-08-05 17:19:50 -05:00
be-coder-07 c6329ec91e test(installer): preregister durable brain P7 integration 2026-08-05 17:19:50 -05:00
be-coder-07 01694f3f98 test(#1051): preregister mosaic-brain acceptance contract 2026-08-05 17:19:50 -05:00
be-coder-08andMos 85d2108e4e fix(ci): remove upgrade rollback signal race (#1060)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: be-coder-08 <[email protected]>
2026-08-05 22:14:15 +00:00
be-coder-08andMos 16f91157a1 test(ci): make queue guard harness deterministic (#1062)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: be-coder-08 <[email protected]>
2026-08-05 21:49:44 +00:00
71 changed files with 7459 additions and 3211 deletions
+5 -104
View File
@@ -1,5 +1,5 @@
# Build, publish npm packages, and push Docker images # Build, publish npm packages, and push Docker images
# Runs on main for stable publishes and on next for integration-line prereleases/images # Runs only on main branch push/tag
variables: variables:
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine + # Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
@@ -23,21 +23,9 @@ variables:
- 'docs/**' - 'docs/**'
- '**/*.md' - '**/*.md'
- '.woodpecker/**' - '.woodpecker/**'
- event: [push, manual]
branch: next
- &main_image_build_when
- event: tag
- event: [push, manual]
branch: main
path:
exclude:
- 'packages/mosaic/**'
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
when: when:
- branch: [main, next] - branch: [main]
event: [push, manual, tag] event: [push, manual, tag]
steps: steps:
@@ -115,84 +103,6 @@ steps:
depends_on: depends_on:
- build - build
publish-next-npm:
image: *node_image
# Durable @next integration-line publish. Runs only on next; never writes
# the latest dist-tag and never commits the computed prerelease versions.
when:
- event: [push, manual]
branch: next
environment:
NPM_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
commands:
- *enable_pnpm
- |
if [ "$CI_COMMIT_BRANCH" != "next" ]; then
echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
exit 1
fi
if [ -z "$CI_PIPELINE_NUMBER" ]; then
echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
exit 1
fi
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
const roots = ['apps', 'packages', 'plugins'];
const updated = [];
function walk(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const packagePath = path.join(fullPath, 'package.json');
if (fs.existsSync(packagePath)) updatePackage(packagePath);
walk(fullPath);
}
}
}
function updatePackage(packagePath) {
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
if (!stableMatch) {
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
}
const [, major, minor, patch] = stableMatch;
const oldVersion = manifest.version;
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
}
for (const root of roots) walk(root);
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
for (const line of updated) console.log('[publish-next] ' + line);
NODE
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
exit 1
fi
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
depends_on:
- build
# TODO: Uncomment when ready to publish to npmjs.org # TODO: Uncomment when ready to publish to npmjs.org
# publish-npmjs: # publish-npmjs:
# image: *node_image # image: *node_image
@@ -224,17 +134,8 @@ steps:
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json - echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- | - |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}" DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "next" ]; then if [ "$CI_COMMIT_BRANCH" = "main" ]; then
if [ -n "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
exit 1
fi
echo "[publish] next gateway publish is sha-only"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest" DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
elif [ -z "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
exit 1
fi fi
if [ -n "$CI_COMMIT_TAG" ]; then if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG" DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
@@ -245,7 +146,7 @@ steps:
build-appservice: build-appservice:
image: gcr.io/kaniko-project/executor:debug image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when when: *image_build_when
environment: environment:
REGISTRY_USER: REGISTRY_USER:
from_secret: gitea_username from_secret: gitea_username
@@ -271,7 +172,7 @@ steps:
build-web: build-web:
image: gcr.io/kaniko-project/executor:debug image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when when: *image_build_when
environment: environment:
REGISTRY_USER: REGISTRY_USER:
from_secret: gitea_username from_secret: gitea_username
+1 -13
View File
@@ -30,16 +30,6 @@ This installs both components:
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` | | **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` | | **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
### Install lanes
| Lane | Command | Use when | Source |
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Build-from-source at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided.
After install, the wizard runs automatically or you can invoke it manually: After install, the wizard runs automatically or you can invoke it manually:
```bash ```bash
@@ -371,9 +361,7 @@ The CLI also performs a background update check on every invocation (cached for
bash tools/install.sh --check # Version check only bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI) bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework) bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --next # Prerelease lane: source build from next bash tools/install.sh --ref v1.0 # Install from a specific git ref
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
bash tools/install.sh --yes # Non-interactive, accept all defaults bash tools/install.sh --yes # Non-interactive, accept all defaults
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
``` ```
@@ -1,519 +0,0 @@
/**
* Federation M3 single-gateway integration tests (FED-M3-10).
*
* Covers MILESTONES.md M3 acceptance:
* - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403.
* - #7: max_rows_per_query caps list results.
*
* Strategy:
* - Real PostgreSQL via @mosaicstack/db.
* - Mocked TLS context/Fastify request shim for FederationAuthGuard.
* - Direct controller calls using the real POST /api/federation/v1/list/:resource contract.
*
* Run:
* FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \
* src/__tests__/integration/federation-m3-list.integration.test.ts
*/
import 'reflect-metadata';
import * as crypto from 'node:crypto';
import type { ExecutionContext } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import type { FastifyReply, FastifyRequest } from 'fastify';
import {
and,
createDb,
eq,
federationGrants,
federationPeers,
inArray,
missionTasks,
missions,
projects,
tasks,
teamMembers,
teams,
type Db,
type DbHandle,
users,
} from '@mosaicstack/db';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { DB } from '../../database/database.module.js';
import { GrantsService } from '../../federation/grants.service.js';
import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js';
import { FederationScopeService } from '../../federation/server/scope.service.js';
import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js';
import { ListController } from '../../federation/server/verbs/list.controller.js';
import {
makeMosaicIssuedCert,
makeSelfSignedCert,
} from '../../federation/__tests__/helpers/test-cert.js';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`;
const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase();
interface TestIds {
readonly subjectUserId: string;
readonly otherUserId: string;
readonly peerId: string;
readonly revokedPeerId: string;
readonly activeGrantId: string;
readonly revokedGrantId: string;
readonly subjectProjectId: string;
readonly subjectMissionId: string;
readonly otherProjectId: string;
readonly teamId: string;
readonly unauthorizedTeamId: string;
readonly teamProjectId: string;
readonly taskIds: readonly string[];
readonly excludedTaskIds: readonly string[];
readonly subjectNoteId: string;
readonly otherUserNoteId: string;
}
function pemToDer(pem: string): Buffer {
return Buffer.from(
pem
.replace(/-----BEGIN CERTIFICATE-----/, '')
.replace(/-----END CERTIFICATE-----/, '')
.replace(/\s+/g, ''),
'base64',
);
}
function makeFederationRequest(certPem: string): FastifyRequest {
return {
raw: {
socket: {
getPeerCertificate: () => ({
raw: pemToDer(certPem),
serialNumber: CERT_SERIAL_HEX,
}),
},
},
} as unknown as FastifyRequest;
}
function makeGuardContext(request: FastifyRequest): {
readonly context: ExecutionContext;
readonly sent: { statusCode?: number; payload?: unknown };
} {
const sent: { statusCode?: number; payload?: unknown } = {};
const reply = {
status: (statusCode: number) => {
sent.statusCode = statusCode;
return {
header: () => ({
send: (payload: unknown) => {
sent.payload = payload;
},
}),
};
},
} as unknown as FastifyReply;
const context = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => reply,
}),
} as unknown as ExecutionContext;
return { context, sent };
}
async function insertUser(db: Db, id: string, label: string): Promise<void> {
await db.insert(users).values({
id,
name: `${RUN_ID}-${label}`,
email: `${RUN_ID}-${label}@federation-test.invalid`,
emailVerified: false,
});
}
async function seedFixtures(db: Db): Promise<TestIds> {
const subjectUserId = `${RUN_ID}-subject`;
const otherUserId = `${RUN_ID}-other`;
const peerId = crypto.randomUUID();
const revokedPeerId = crypto.randomUUID();
const activeGrantId = crypto.randomUUID();
const revokedGrantId = crypto.randomUUID();
const subjectProjectId = crypto.randomUUID();
const subjectMissionId = crypto.randomUUID();
const otherProjectId = crypto.randomUUID();
const teamId = crypto.randomUUID();
const unauthorizedTeamId = crypto.randomUUID();
const teamProjectId = crypto.randomUUID();
const taskIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()] as const;
const excludedTaskIds = [crypto.randomUUID(), crypto.randomUUID()] as const;
const subjectNoteId = crypto.randomUUID();
const otherUserNoteId = crypto.randomUUID();
await insertUser(db, subjectUserId, 'subject');
await insertUser(db, otherUserId, 'other');
await db.insert(teams).values([
{
id: teamId,
name: `${RUN_ID} allowed team`,
slug: `${RUN_ID}-allowed-team`,
ownerId: subjectUserId,
managerId: subjectUserId,
},
{
id: unauthorizedTeamId,
name: `${RUN_ID} unauthorized team`,
slug: `${RUN_ID}-unauthorized-team`,
ownerId: otherUserId,
managerId: otherUserId,
},
]);
await db.insert(teamMembers).values([
{ teamId, userId: subjectUserId, role: 'member' },
{ teamId: unauthorizedTeamId, userId: subjectUserId, role: 'member' },
]);
await db.insert(projects).values([
{
id: subjectProjectId,
name: `${RUN_ID} subject personal project`,
ownerType: 'user',
ownerId: subjectUserId,
},
{
id: otherProjectId,
name: `${RUN_ID} other personal project`,
ownerType: 'user',
ownerId: otherUserId,
},
{
id: teamProjectId,
name: `${RUN_ID} unauthorized team project`,
ownerType: 'team',
teamId: unauthorizedTeamId,
},
]);
await db.insert(missions).values({
id: subjectMissionId,
name: `${RUN_ID} subject mission`,
projectId: subjectProjectId,
userId: subjectUserId,
});
await db.insert(tasks).values([
{
id: taskIds[0],
title: `${RUN_ID} visible task 1`,
missionId: subjectMissionId,
createdAt: new Date('2026-06-25T03:00:00.000Z'),
updatedAt: new Date('2026-06-25T03:00:00.000Z'),
},
{
id: taskIds[1],
title: `${RUN_ID} visible task 2`,
projectId: subjectProjectId,
createdAt: new Date('2026-06-25T02:00:00.000Z'),
updatedAt: new Date('2026-06-25T02:00:00.000Z'),
},
{
id: taskIds[2],
title: `${RUN_ID} visible task 3`,
projectId: subjectProjectId,
createdAt: new Date('2026-06-25T01:00:00.000Z'),
updatedAt: new Date('2026-06-25T01:00:00.000Z'),
},
{
id: excludedTaskIds[0],
title: `${RUN_ID} other user task`,
projectId: otherProjectId,
createdAt: new Date('2026-06-25T04:00:00.000Z'),
updatedAt: new Date('2026-06-25T04:00:00.000Z'),
},
{
id: excludedTaskIds[1],
title: `${RUN_ID} unauthorized team task`,
projectId: teamProjectId,
createdAt: new Date('2026-06-25T05:00:00.000Z'),
updatedAt: new Date('2026-06-25T05:00:00.000Z'),
},
]);
await db.insert(missionTasks).values([
{
id: subjectNoteId,
missionId: subjectMissionId,
userId: subjectUserId,
notes: `${RUN_ID} subject visible note`,
createdAt: new Date('2026-06-25T03:30:00.000Z'),
updatedAt: new Date('2026-06-25T03:30:00.000Z'),
},
{
id: otherUserNoteId,
missionId: subjectMissionId,
userId: otherUserId,
notes: `${RUN_ID} other user note on subject mission`,
createdAt: new Date('2026-06-25T04:30:00.000Z'),
updatedAt: new Date('2026-06-25T04:30:00.000Z'),
},
]);
await db.insert(federationPeers).values([
{
id: peerId,
commonName: `${RUN_ID}-active-peer`,
displayName: `${RUN_ID} Active Peer`,
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: CERT_SERIAL_HEX,
certNotAfter: new Date(Date.now() + 86_400_000),
state: 'active',
},
{
id: revokedPeerId,
commonName: `${RUN_ID}-revoked-peer`,
displayName: `${RUN_ID} Revoked Peer`,
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: `${CERT_SERIAL_HEX}${RUN_ID.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
certNotAfter: new Date(Date.now() + 86_400_000),
state: 'active',
},
]);
await db.insert(federationGrants).values([
{
id: activeGrantId,
peerId,
subjectUserId,
status: 'active',
scope: {
resources: ['tasks', 'notes'],
excluded_resources: [],
filters: {
tasks: { include_personal: true, include_teams: [] },
notes: { include_personal: true, include_teams: [] },
},
max_rows_per_query: 2,
},
},
{
id: revokedGrantId,
peerId,
subjectUserId,
status: 'revoked',
revokedAt: new Date(),
revokedReason: `${RUN_ID} revoked grant fixture`,
scope: {
resources: ['tasks'],
excluded_resources: [],
max_rows_per_query: 2,
},
},
]);
return {
subjectUserId,
otherUserId,
peerId,
revokedPeerId,
activeGrantId,
revokedGrantId,
subjectProjectId,
subjectMissionId,
otherProjectId,
teamId,
unauthorizedTeamId,
teamProjectId,
taskIds,
excludedTaskIds,
subjectNoteId,
otherUserNoteId,
};
}
async function cleanupFixtures(db: Db, ids: TestIds | undefined): Promise<void> {
if (!ids) {
return;
}
await db
.delete(missionTasks)
.where(inArray(missionTasks.id, [ids.subjectNoteId, ids.otherUserNoteId]))
.catch(() => {});
await db
.delete(tasks)
.where(inArray(tasks.id, [...ids.taskIds, ...ids.excludedTaskIds]))
.catch(() => {});
await db
.delete(missions)
.where(eq(missions.id, ids.subjectMissionId))
.catch(() => {});
await db
.delete(projects)
.where(inArray(projects.id, [ids.subjectProjectId, ids.otherProjectId, ids.teamProjectId]))
.catch(() => {});
await db
.delete(teamMembers)
.where(
and(
eq(teamMembers.userId, ids.subjectUserId),
inArray(teamMembers.teamId, [ids.teamId, ids.unauthorizedTeamId]),
),
)
.catch(() => {});
await db
.delete(teams)
.where(inArray(teams.id, [ids.teamId, ids.unauthorizedTeamId]))
.catch(() => {});
await db
.delete(federationGrants)
.where(inArray(federationGrants.id, [ids.activeGrantId, ids.revokedGrantId]))
.catch(() => {});
await db
.delete(federationPeers)
.where(inArray(federationPeers.id, [ids.peerId, ids.revokedPeerId]))
.catch(() => {});
await db
.delete(users)
.where(inArray(users.id, [ids.subjectUserId, ids.otherUserId]))
.catch(() => {});
}
describe.skipIf(!run)('federation M3 list verb — single-gateway integration', () => {
let handle: DbHandle;
let db: Db;
let moduleRef: TestingModule;
let guard: FederationAuthGuard;
let listController: ListController;
let ids: TestIds | undefined;
beforeAll(async () => {
handle = createDb(PG_URL);
db = handle.db;
ids = await seedFixtures(db);
moduleRef = await Test.createTestingModule({
controllers: [ListController],
providers: [
{ provide: DB, useValue: db },
GrantsService,
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
],
}).compile();
guard = moduleRef.get(FederationAuthGuard);
listController = moduleRef.get(ListController);
}, 30_000);
afterAll(async () => {
await moduleRef?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
await cleanupFixtures(db, ids).catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
await handle?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
});
it('#6 — rejects a client cert with malformed/missing Mosaic OIDs with 401', async () => {
const malformedOidCert = await makeSelfSignedCert();
const request = makeFederationRequest(malformedOidCert);
const { context, sent } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(false);
expect(sent.statusCode).toBe(401);
expect(sent.payload).toMatchObject({
error: {
code: 'unauthorized',
message: expect.stringContaining('missing required OID'),
},
});
expect(request.federationContext).toBeUndefined();
});
it('#6 — rejects a valid client cert when its grant is revoked with 403', async () => {
expect(ids).toBeDefined();
const revokedCert = await makeMosaicIssuedCert({
grantId: ids!.revokedGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(revokedCert);
const { context, sent } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(false);
expect(sent.statusCode).toBe(403);
expect(sent.payload).toMatchObject({
error: {
code: 'forbidden',
message: 'Federation access denied',
},
});
expect(request.federationContext).toBeUndefined();
});
it('#7 — enforces max_rows_per_query on POST /api/federation/v1/list/:resource', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
const response = await listController.list('tasks', request, { limit: 100 });
const returnedIds = response.items.map((item) => item['id']);
expect(response.items).toHaveLength(2);
expect(response._truncated).toBe(true);
expect(response.nextCursor).toEqual(expect.any(String));
expect(returnedIds).toEqual([ids!.taskIds[0], ids!.taskIds[1]]);
expect(returnedIds).not.toContain(ids!.taskIds[2]);
for (const excludedId of ids!.excludedTaskIds) {
expect(returnedIds).not.toContain(excludedId);
}
expect(response.items.every((item) => item._source === 'local')).toBe(true);
});
it('excludes another user mission task notes on the same authorized mission', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
const response = await listController.list('notes', request, { limit: 10 });
const returnedIds = response.items.map((item) => item['id']);
expect(returnedIds).toEqual([ids!.subjectNoteId]);
expect(returnedIds).not.toContain(ids!.otherUserNoteId);
expect(response.items.every((item) => item._source === 'local')).toBe(true);
});
it('fails closed for unsupported list resources', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
await expect(listController.list('widgets', request, {})).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Requested federation resource is not supported',
},
},
status: 403,
});
});
});
@@ -1,11 +1,9 @@
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common'; import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { sql, type Db } from '@mosaicstack/db'; import { sql, type Db } from '@mosaicstack/db';
import { createQueue } from '@mosaicstack/queue'; import { createQueue } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { DB } from '../database/database.module.js'; import { DB } from '../database/database.module.js';
import { AgentService } from '../agent/agent.service.js'; import { AgentService } from '../agent/agent.service.js';
import { ProviderService } from '../agent/provider.service.js'; import { ProviderService } from '../agent/provider.service.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { AdminGuard } from './admin.guard.js'; import { AdminGuard } from './admin.guard.js';
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js'; import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
@@ -16,9 +14,6 @@ export class AdminHealthController {
@Inject(DB) private readonly db: Db, @Inject(DB) private readonly db: Db,
@Inject(AgentService) private readonly agentService: AgentService, @Inject(AgentService) private readonly agentService: AgentService,
@Inject(ProviderService) private readonly providerService: ProviderService, @Inject(ProviderService) private readonly providerService: ProviderService,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {} ) {}
@Get() @Get()
@@ -60,14 +55,6 @@ export class AdminHealthController {
} }
private async checkCache(): Promise<ServiceStatusDto> { private async checkCache(): Promise<ServiceStatusDto> {
// On Local tier there is no Redis. The cache is intentionally absent, which
// is a healthy state for this tier — report 'ok' rather than opening a new
// ioredis connection on every admin health check (which would spam
// ECONNREFUSED and create/destroy a connection per request). latencyMs 0
// signals "no cache backend to measure" for this tier.
if (this.mosaicConfig?.queue?.type === 'local') {
return { status: 'ok', latencyMs: 0 };
}
const start = Date.now(); const start = Date.now();
const handle = createQueue(); const handle = createQueue();
try { try {
@@ -72,13 +72,13 @@ const mockChatGateway = {
broadcastSessionInfo: vi.fn(), broadcastSessionInfo: vi.fn(),
}; };
function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService { function buildService(): CommandExecutorService {
return new CommandExecutorService( return new CommandExecutorService(
mockRegistry as never, mockRegistry as never,
mockAgentService as never, mockAgentService as never,
mockSystemOverride as never, mockSystemOverride as never,
mockSessionGC as never, mockSessionGC as never,
redis as never, mockRedis as never,
mockBrain as never, mockBrain as never,
null, null,
mockChatGateway as never, mockChatGateway as never,
@@ -131,22 +131,6 @@ describe('CommandExecutorService — P8-012 commands', () => {
expect(ttl).toBe(300); expect(ttl).toBe(300);
}); });
it('/provider login remains available without Redis on the local tier', async () => {
const localService = buildService(null);
const payload: SlashCommandPayload = {
command: 'provider',
args: 'login anthropic',
conversationId,
};
const result = await localService.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).not.toContain('token=');
expect(result.data).toEqual({ provider: 'anthropic' });
expect(mockRedis.set).not.toHaveBeenCalled();
});
// /provider with no args — returns usage // /provider with no args — returns usage
it('/provider with no args returns usage message', async () => { it('/provider with no args returns usage message', async () => {
const payload: SlashCommandPayload = { command: 'provider', conversationId }; const payload: SlashCommandPayload = { command: 'provider', conversationId };
@@ -23,10 +23,7 @@ export class CommandExecutorService {
@Inject(AgentService) private readonly agentService: AgentService, @Inject(AgentService) private readonly agentService: AgentService,
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService, @Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
@Inject(SessionGCService) private readonly sessionGC: SessionGCService, @Inject(SessionGCService) private readonly sessionGC: SessionGCService,
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped. @Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
@Optional()
@Inject(COMMANDS_REDIS)
private readonly redis: QueueHandle['redis'] | null,
@Inject(BRAIN) private readonly brain: Brain, @Inject(BRAIN) private readonly brain: Brain,
@Optional() @Optional()
@Inject(forwardRef(() => ReloadService)) @Inject(forwardRef(() => ReloadService))
@@ -446,7 +443,6 @@ export class CommandExecutorService {
byte.toString(16).padStart(2, '0'), byte.toString(16).padStart(2, '0'),
).join(''); ).join('');
const key = `mosaic:auth:poll:${tokenHash}`; const key = `mosaic:auth:poll:${tokenHash}`;
if (this.redis) {
// Persist only a short-lived token digest. The raw token is delivered only by // Persist only a short-lived token digest. The raw token is delivered only by
// the authenticated dashboard flow, never in chat output or command metadata. // the authenticated dashboard flow, never in chat output or command metadata.
await this.redis.set( await this.redis.set(
@@ -455,7 +451,6 @@ export class CommandExecutorService {
'EX', 'EX',
300, 300,
); );
}
return { return {
command: 'provider', command: 'provider',
success: true, success: true,
+5 -15
View File
@@ -1,7 +1,5 @@
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common'; import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue'; import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { ChatModule } from '../chat/chat.module.js'; import { ChatModule } from '../chat/chat.module.js';
import { GCModule } from '../gc/gc.module.js'; import { GCModule } from '../gc/gc.module.js';
import { ReloadModule } from '../reload/reload.module.js'; import { ReloadModule } from '../reload/reload.module.js';
@@ -18,17 +16,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
providers: [ providers: [
{ {
provide: COMMANDS_QUEUE_HANDLE, provide: COMMANDS_QUEUE_HANDLE,
useFactory: (config: MosaicConfig | null): QueueHandle | null => { useFactory: (): QueueHandle => {
// On Local tier there is no Redis — skip the ioredis connection.
// CommandExecutorService falls back to no-cache for /provider login on local.
if (config?.queue?.type === 'local') return null;
return createQueue(); return createQueue();
}, },
inject: [MOSAIC_CONFIG],
}, },
{ {
provide: COMMANDS_REDIS, provide: COMMANDS_REDIS,
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null, useFactory: (handle: QueueHandle) => handle.redis,
inject: [COMMANDS_QUEUE_HANDLE], inject: [COMMANDS_QUEUE_HANDLE],
}, },
CommandRegistryService, CommandRegistryService,
@@ -44,13 +38,9 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
], ],
}) })
export class CommandsModule implements OnApplicationShutdown { export class CommandsModule implements OnApplicationShutdown {
constructor( constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
@Optional()
@Inject(COMMANDS_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
async onApplicationShutdown(): Promise<void> { async onApplicationShutdown(): Promise<void> {
await this.handle?.close().catch(() => {}); await this.handle.close().catch(() => {});
} }
} }
@@ -5,8 +5,6 @@ import { EnrollmentController } from './enrollment.controller.js';
import { EnrollmentService } from './enrollment.service.js'; import { EnrollmentService } from './enrollment.service.js';
import { FederationController } from './federation.controller.js'; import { FederationController } from './federation.controller.js';
import { CapabilitiesController } from './server/verbs/capabilities.controller.js'; import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
import { GetController } from './server/verbs/get.controller.js';
import { FederationGetQueryService } from './server/verbs/get-query.service.js';
import { GrantsService } from './grants.service.js'; import { GrantsService } from './grants.service.js';
import { FederationClientService, QuerySourceService } from './client/index.js'; import { FederationClientService, QuerySourceService } from './client/index.js';
import { FederationAuthGuard, FederationScopeService } from './server/index.js'; import { FederationAuthGuard, FederationScopeService } from './server/index.js';
@@ -14,13 +12,7 @@ import { ListController } from './server/verbs/list.controller.js';
import { FederationListQueryService } from './server/verbs/list-query.service.js'; import { FederationListQueryService } from './server/verbs/list-query.service.js';
@Module({ @Module({
controllers: [ controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
EnrollmentController,
FederationController,
CapabilitiesController,
ListController,
GetController,
],
providers: [ providers: [
AdminGuard, AdminGuard,
CaService, CaService,
@@ -31,7 +23,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
FederationAuthGuard, FederationAuthGuard,
FederationScopeService, FederationScopeService,
FederationListQueryService, FederationListQueryService,
FederationGetQueryService,
], ],
exports: [ exports: [
CaService, CaService,
@@ -42,7 +33,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
FederationAuthGuard, FederationAuthGuard,
FederationScopeService, FederationScopeService,
FederationListQueryService, FederationListQueryService,
FederationGetQueryService,
], ],
}) })
export class FederationModule {} export class FederationModule {}
@@ -1,348 +0,0 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
createPgliteDb,
missionTasks,
missions,
projects,
runPgliteMigrations,
teams,
users,
type Db,
type DbHandle,
} from '@mosaicstack/db';
import type { FederationScopeQueryFilter } from '../../scope.service.js';
import { FederationGetQueryService } from '../get-query.service.js';
const CREDENTIAL_FILTER: FederationScopeQueryFilter = {
resource: 'credentials',
subjectUserId: 'user-1',
includePersonal: true,
teamIds: [],
limit: 1,
maxRowsPerQuery: 25,
};
const SUBJECT_USER_ID = 'fed-m3-06-subject';
const OTHER_USER_ID = 'fed-m3-06-other';
const TEAM_ID = '06000000-0000-4000-8000-000000000001';
const UNAUTHORIZED_TEAM_ID = '06000000-0000-4000-8000-000000000002';
const PERSONAL_PROJECT_ID = '06000000-0000-4000-8000-000000000101';
const TEAM_PROJECT_ID = '06000000-0000-4000-8000-000000000102';
const UNAUTHORIZED_PROJECT_ID = '06000000-0000-4000-8000-000000000103';
const PERSONAL_MISSION_ID = '06000000-0000-4000-8000-000000000201';
const TEAM_MISSION_ID = '06000000-0000-4000-8000-000000000202';
const UNAUTHORIZED_MISSION_ID = '06000000-0000-4000-8000-000000000203';
const SUBJECT_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000301';
const OTHER_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000302';
const SUBJECT_PERSONAL_NOTE_ID = '06000000-0000-4000-8000-000000000303';
const SUBJECT_UNAUTHORIZED_NOTE_ID = '06000000-0000-4000-8000-000000000304';
let dbHandle: DbHandle | undefined;
function makeService() {
return new FederationGetQueryService({} as Db);
}
function makeDbService() {
if (!dbHandle) {
throw new Error('test DB not initialized');
}
return new FederationGetQueryService(dbHandle.db);
}
async function seedNotesFixture() {
if (!dbHandle) {
throw new Error('test DB not initialized');
}
await dbHandle.db.insert(users).values([
{
id: SUBJECT_USER_ID,
name: 'Federation Subject',
email: `${SUBJECT_USER_ID}@example.test`,
emailVerified: false,
},
{
id: OTHER_USER_ID,
name: 'Federation Other',
email: `${OTHER_USER_ID}@example.test`,
emailVerified: false,
},
]);
await dbHandle.db.insert(teams).values([
{
id: TEAM_ID,
name: 'FED-M3-06 Team',
slug: 'fed-m3-06-team',
ownerId: SUBJECT_USER_ID,
managerId: SUBJECT_USER_ID,
},
{
id: UNAUTHORIZED_TEAM_ID,
name: 'FED-M3-06 Unauthorized Team',
slug: 'fed-m3-06-unauthorized-team',
ownerId: OTHER_USER_ID,
managerId: OTHER_USER_ID,
},
]);
await dbHandle.db.insert(projects).values([
{
id: PERSONAL_PROJECT_ID,
name: 'FED-M3-06 Personal Project',
ownerId: SUBJECT_USER_ID,
ownerType: 'user',
},
{
id: TEAM_PROJECT_ID,
name: 'FED-M3-06 Team Project',
teamId: TEAM_ID,
ownerType: 'team',
},
{
id: UNAUTHORIZED_PROJECT_ID,
name: 'FED-M3-06 Unauthorized Project',
teamId: UNAUTHORIZED_TEAM_ID,
ownerType: 'team',
},
]);
await dbHandle.db.insert(missions).values([
{
id: PERSONAL_MISSION_ID,
name: 'FED-M3-06 Personal Mission',
projectId: PERSONAL_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
{
id: TEAM_MISSION_ID,
name: 'FED-M3-06 Team Mission',
projectId: TEAM_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
{
id: UNAUTHORIZED_MISSION_ID,
name: 'FED-M3-06 Unauthorized Mission',
projectId: UNAUTHORIZED_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
]);
await dbHandle.db.insert(missionTasks).values([
{
id: SUBJECT_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note on team mission',
createdAt: new Date('2026-06-24T03:00:00.000Z'),
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
},
{
id: OTHER_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
userId: OTHER_USER_ID,
notes: 'other user note on team mission',
createdAt: new Date('2026-06-24T02:00:00.000Z'),
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
},
{
id: SUBJECT_PERSONAL_NOTE_ID,
missionId: PERSONAL_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note on personal mission',
createdAt: new Date('2026-06-24T01:00:00.000Z'),
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
},
{
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
missionId: UNAUTHORIZED_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note outside grant-visible missions',
createdAt: new Date('2026-06-24T04:00:00.000Z'),
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
},
]);
}
describe('FederationGetQueryService', () => {
beforeAll(async () => {
dbHandle = createPgliteDb(`memory://fed-m3-06-get-${Date.now()}`);
await runPgliteMigrations(dbHandle);
await seedNotesFixture();
});
afterAll(async () => {
await dbHandle?.close();
dbHandle = undefined;
});
it('denies sensitive resources in native RBAC for M3 get reads', async () => {
const service = makeService();
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'credentials',
}),
).resolves.toMatchObject({
allowed: false,
reason: 'credentials federation get access is not implemented in M3',
});
});
it('allows personal memory reads without requiring team lookup', async () => {
const service = makeService();
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'memory',
}),
).resolves.toEqual({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
});
it('uses subject team membership as the native RBAC upper bound for task and note reads', async () => {
const service = makeService();
const listSubjectTeamIds = vi.fn().mockResolvedValue(['team-1', 'team-2']);
(
service as unknown as {
listSubjectTeamIds: (subjectUserId: string) => Promise<string[]>;
}
).listSubjectTeamIds = listSubjectTeamIds;
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'tasks',
}),
).resolves.toEqual({
allowed: true,
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
});
expect(listSubjectTeamIds).toHaveBeenCalledWith('user-1');
});
it('does not query storage for sensitive get resources even if scope allowed them', async () => {
const service = makeService();
await expect(service.get({ filter: CREDENTIAL_FILTER, id: 'cred-1' })).resolves.toEqual({
status: 'denied',
reason: 'credentials federation get is not implemented',
});
});
it('fails closed for unsupported resources instead of returning undefined', async () => {
const service = makeService();
await expect(
service.get({
filter: {
...CREDENTIAL_FILTER,
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
},
id: 'row-1',
}),
).resolves.toEqual({
status: 'denied',
reason: 'Unsupported federation get resource: unknown-resource',
});
});
it('does not leak another user mission task note through team-scoped get reads', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: OTHER_TEAM_NOTE_ID,
}),
).resolves.toEqual({
status: 'denied',
reason: 'Note is outside the federated scope',
});
});
it('does not return subject notes from missions outside the grant-visible project set', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: true,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
}),
).resolves.toEqual({
status: 'denied',
reason: 'Note is outside the federated scope',
});
});
it('returns a subject note only when subject ownership and authorized mission intersect', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: SUBJECT_TEAM_NOTE_ID,
}),
).resolves.toMatchObject({
status: 'found',
item: {
id: SUBJECT_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
content: 'subject note on team mission',
},
});
});
it('does not return subject personal notes when includePersonal is false', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: SUBJECT_PERSONAL_NOTE_ID,
}),
).resolves.toEqual({
status: 'denied',
reason: 'Note is outside the federated scope',
});
});
});
@@ -1,207 +0,0 @@
import 'reflect-metadata';
import { RequestMethod } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FederationAuthGuard } from '../../federation-auth.guard.js';
import type {
FederationScopeEvaluationResult,
FederationScopeQueryFilter,
} from '../../scope.service.js';
import { GetController } from '../get.controller.js';
import type { FederationGetQueryResult } from '../get-query.service.js';
const FEDERATION_CONTEXT = {
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
scope: { resources: ['tasks'], max_rows_per_query: 25 },
};
const TASK_FILTER: FederationScopeQueryFilter = {
resource: 'tasks',
subjectUserId: 'user-1',
includePersonal: true,
teamIds: ['team-1'],
limit: 1,
maxRowsPerQuery: 25,
};
function makeRequest(): FastifyRequest {
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
}
function allowedScope(
filter: FederationScopeQueryFilter = TASK_FILTER,
): FederationScopeEvaluationResult {
return { allowed: true, filter };
}
function makeController(opts?: {
scopeResult?: FederationScopeEvaluationResult;
queryResult?: FederationGetQueryResult;
}) {
const scope = {
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
};
const query = {
evaluateReadAccess: vi.fn(),
get: vi.fn().mockResolvedValue(
opts?.queryResult ?? {
status: 'found',
item: {
id: 'task-1',
title: 'Federated task',
createdAt: new Date('2026-06-24T00:00:00.000Z'),
},
},
),
};
return {
controller: new GetController(scope as never, query as never),
scope,
query,
};
}
describe('GetController', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('declares POST /api/federation/v1/get/:resource/:id protected only by FederationAuthGuard', () => {
expect(Reflect.getMetadata('path', GetController)).toBe('api/federation/v1/get');
expect(Reflect.getMetadata('path', GetController.prototype.get)).toBe(':resource/:id');
expect(Reflect.getMetadata('method', GetController.prototype.get)).toBe(RequestMethod.POST);
expect(Reflect.getMetadata('__guards__', GetController)).toEqual([FederationAuthGuard]);
});
it('runs AuthGuard context through ScopeService and returns one local-source tagged row', async () => {
const { controller, scope, query } = makeController();
const response = await controller.get('tasks', 'task-1', makeRequest());
expect(scope.evaluateAccess).toHaveBeenCalledWith({
context: FEDERATION_CONTEXT,
resource: 'tasks',
requestedLimit: 1,
nativeRbac: query,
});
expect(query.get).toHaveBeenCalledWith({ filter: TASK_FILTER, id: 'task-1' });
expect(response).toEqual({
item: {
id: 'task-1',
title: 'Federated task',
createdAt: new Date('2026-06-24T00:00:00.000Z'),
_source: 'local',
},
});
});
it('returns a federation error envelope when auth guard context is missing', async () => {
const { controller, scope, query } = makeController();
await expect(
controller.get('tasks', 'task-1', {} as unknown as FastifyRequest),
).rejects.toMatchObject({
response: {
error: {
code: 'unauthorized',
message: 'Federation context missing',
},
},
status: 401,
});
expect(scope.evaluateAccess).not.toHaveBeenCalled();
expect(query.get).not.toHaveBeenCalled();
});
it('returns a federation error envelope when scope evaluation denies access', async () => {
const { controller, query } = makeController({
scopeResult: {
allowed: false,
deny: {
code: 'resource_excluded',
stage: 'resource_exclusion',
statusCode: 403,
message: 'Requested federation resource is explicitly excluded by grant scope',
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'credentials',
},
},
});
await expect(controller.get('credentials', 'cred-1', makeRequest())).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Requested federation resource is explicitly excluded by grant scope',
},
},
status: 403,
});
expect(query.get).not.toHaveBeenCalled();
});
it('returns 404 when the scoped query layer cannot find the resource id', async () => {
const { controller } = makeController({ queryResult: { status: 'not_found' } });
await expect(controller.get('tasks', 'missing-task', makeRequest())).rejects.toMatchObject({
response: { error: { code: 'not_found' } },
status: 404,
});
});
it('returns 403 when the resource exists outside the RBAC/scope intersection', async () => {
const { controller } = makeController({
queryResult: { status: 'denied', reason: 'Task is outside the federated scope' },
});
await expect(controller.get('tasks', 'task-2', makeRequest())).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Task is outside the federated scope',
},
},
status: 403,
});
});
it('fails closed when the query layer denies an unsupported resource', async () => {
const unsupportedFilter: FederationScopeQueryFilter = {
...TASK_FILTER,
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
};
const { controller } = makeController({
scopeResult: allowedScope(unsupportedFilter),
queryResult: {
status: 'denied',
reason: 'Unsupported federation get resource: unknown-resource',
},
});
await expect(controller.get('unknown-resource', 'row-1', makeRequest())).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Unsupported federation get resource: unknown-resource',
},
},
status: 403,
});
});
it('rejects empty ids before evaluating scope', async () => {
const { controller, scope, query } = makeController();
await expect(controller.get('tasks', ' ', makeRequest())).rejects.toMatchObject({
response: { error: { code: 'invalid_request' } },
status: 400,
});
expect(scope.evaluateAccess).not.toHaveBeenCalled();
expect(query.get).not.toHaveBeenCalled();
});
});
@@ -1,311 +0,0 @@
/**
* Federation get query layer (FED-M3-06).
*
* Read-only DB adapter used by GetController after FederationAuthGuard and
* FederationScopeService have established the subject user, allowed resource,
* native-RBAC intersection, and row cap. Audit writes are intentionally
* deferred to M4.
*/
import { Inject, Injectable } from '@nestjs/common';
import {
and,
eq,
inArray,
insights,
or,
missionTasks,
missions,
preferences,
projects,
tasks,
teamMembers,
type Db,
} from '@mosaicstack/db';
import { DB } from '../../../database/database.module.js';
import type {
FederationNativeRbacEvaluator,
FederationNativeRbacRequest,
FederationNativeRbacResult,
FederationScopeQueryFilter,
} from '../scope.service.js';
export interface FederationGetQueryRequest {
readonly filter: FederationScopeQueryFilter;
readonly id: string;
}
export interface FederationGetQueryFoundResult<T extends object = Record<string, unknown>> {
readonly status: 'found';
readonly item: T;
}
export interface FederationGetQueryNotFoundResult {
readonly status: 'not_found';
}
export interface FederationGetQueryDeniedResult {
readonly status: 'denied';
readonly reason: string;
}
export type FederationGetQueryResult<T extends object = Record<string, unknown>> =
| FederationGetQueryFoundResult<T>
| FederationGetQueryNotFoundResult
| FederationGetQueryDeniedResult;
type RowObject = Record<string, unknown>;
function firstRow<T>(rows: T[]): T | undefined {
return rows[0];
}
function rowBelongsToAccessibleProjectOrMission(
row: { projectId?: string | null; missionId?: string | null },
projectIds: readonly string[],
missionIds: readonly string[],
): boolean {
return (
(typeof row.projectId === 'string' && projectIds.includes(row.projectId)) ||
(typeof row.missionId === 'string' && missionIds.includes(row.missionId))
);
}
@Injectable()
export class FederationGetQueryService implements FederationNativeRbacEvaluator {
constructor(@Inject(DB) private readonly db: Db) {}
async evaluateReadAccess(
request: FederationNativeRbacRequest,
): Promise<FederationNativeRbacResult> {
if (request.resource === 'credentials' || request.resource === 'api_keys') {
return {
allowed: false,
reason: `${request.resource} federation get access is not implemented in M3`,
details: { resource: request.resource },
};
}
if (request.resource === 'memory') {
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
}
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
return { allowed: true, access: { includePersonal: true, teamIds } };
}
async get<T extends RowObject = RowObject>(
request: FederationGetQueryRequest,
): Promise<FederationGetQueryResult<T>> {
return this.getByResource(request.filter, request.id) as Promise<FederationGetQueryResult<T>>;
}
private async getByResource(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
switch (filter.resource) {
case 'tasks':
return this.getTask(filter, id);
case 'notes':
return this.getNote(filter, id);
case 'memory':
return this.getMemory(filter, id);
case 'credentials':
case 'api_keys':
return { status: 'denied', reason: `${filter.resource} federation get is not implemented` };
default:
return {
status: 'denied',
reason: `Unsupported federation get resource: ${String(filter.resource)}`,
};
}
}
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
const rows = await this.db
.select({ teamId: teamMembers.teamId })
.from(teamMembers)
.where(eq(teamMembers.userId, subjectUserId));
return rows.map((row) => row.teamId);
}
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
const clauses = [];
if (filter.includePersonal) {
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
}
if (filter.teamIds.length > 0) {
// Project team ownership follows TeamsService.canAccessProject: team-owned
// rows are authorized through projects.teamId, while ownerId remains the
// user who created/bootstrapped the project.
clauses.push(
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
);
}
if (clauses.length === 0) {
return [];
}
const rows = await this.db
.select({ id: projects.id })
.from(projects)
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
return rows.map((row) => row.id);
}
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
if (projectIds.length === 0) {
return [];
}
const rows = await this.db
.select({ id: missions.id })
.from(missions)
.where(inArray(missions.projectId, [...projectIds]));
return rows.map((row) => row.id);
}
private async getTask(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
const row = firstRow(
await this.db
.select({
id: tasks.id,
title: tasks.title,
description: tasks.description,
status: tasks.status,
priority: tasks.priority,
projectId: tasks.projectId,
missionId: tasks.missionId,
assignee: tasks.assignee,
tags: tasks.tags,
dueDate: tasks.dueDate,
metadata: tasks.metadata,
createdAt: tasks.createdAt,
updatedAt: tasks.updatedAt,
})
.from(tasks)
.where(eq(tasks.id, id))
.limit(1),
);
if (!row) {
return { status: 'not_found' };
}
const projectIds = await this.listAccessibleProjectIds(filter);
const missionIds = await this.listMissionIds(projectIds);
if (!rowBelongsToAccessibleProjectOrMission(row, projectIds, missionIds)) {
return { status: 'denied', reason: 'Task is outside the federated scope' };
}
return { status: 'found', item: row as RowObject };
}
private async getNote(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
const row = firstRow(
await this.db
.select({
id: missionTasks.id,
missionId: missionTasks.missionId,
taskId: missionTasks.taskId,
userId: missionTasks.userId,
status: missionTasks.status,
content: missionTasks.notes,
createdAt: missionTasks.createdAt,
updatedAt: missionTasks.updatedAt,
})
.from(missionTasks)
.where(eq(missionTasks.id, id))
.limit(1),
);
if (!row || row.content === null || row.content === '') {
return { status: 'not_found' };
}
const projectIds = await this.listAccessibleProjectIds(filter);
const missionIds = await this.listMissionIds(projectIds);
// mission_tasks rows are user-scoped even when the mission belongs to a team.
// Scope-visible missions must intersect with subject ownership; team scope
// narrows mission IDs but never widens note reads to another user's rows.
if (row.userId !== filter.subjectUserId || !missionIds.includes(row.missionId)) {
return { status: 'denied', reason: 'Note is outside the federated scope' };
}
const item = { ...row } as RowObject;
delete item['userId'];
return { status: 'found', item };
}
private async getMemory(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
const [insightRow, preferenceRow] = await Promise.all([
this.db
.select({
id: insights.id,
userId: insights.userId,
kind: insights.source,
content: insights.content,
category: insights.category,
relevanceScore: insights.relevanceScore,
metadata: insights.metadata,
createdAt: insights.createdAt,
updatedAt: insights.updatedAt,
})
.from(insights)
.where(eq(insights.id, id))
.limit(1)
.then(firstRow),
this.db
.select({
id: preferences.id,
userId: preferences.userId,
kind: preferences.category,
key: preferences.key,
value: preferences.value,
source: preferences.source,
mutable: preferences.mutable,
createdAt: preferences.createdAt,
updatedAt: preferences.updatedAt,
})
.from(preferences)
.where(eq(preferences.id, id))
.limit(1)
.then(firstRow),
]);
const candidates = [insightRow, preferenceRow].filter(
(row): row is NonNullable<typeof row> => row !== undefined,
);
if (candidates.length === 0) {
return { status: 'not_found' };
}
if (!filter.includePersonal) {
return { status: 'denied', reason: 'Memory personal rows are outside the federated scope' };
}
const accessible = candidates.find((row) => row.userId === filter.subjectUserId);
if (!accessible) {
return { status: 'denied', reason: 'Memory row belongs to another subject user' };
}
const item = { ...accessible } as RowObject;
delete item['userId'];
return { status: 'found', item };
}
}
@@ -1,100 +0,0 @@
/**
* Federation get verb (FED-M3-06).
*
* POST /api/federation/v1/get/:resource/:id
*
* Pipeline: FederationAuthGuard attaches the active grant context, then
* FederationScopeService enforces grant scope + native RBAC intersection, then
* the read-only query layer fetches one local row and tags it with `_source`.
* Read audit-log writes are deferred to M4; this controller does not persist
* request or response bodies.
*/
import { Controller, HttpException, Inject, Param, Post, Req, UseGuards } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import {
FederationInvalidRequestError,
FederationNotFoundError,
FederationScopeViolationError,
FederationUnauthorizedError,
SOURCE_LOCAL,
type FederationGetResponse,
type SourceTag,
} from '@mosaicstack/types';
import { FederationAuthGuard } from '../federation-auth.guard.js';
import '../federation-context.js';
import { FederationScopeService } from '../scope.service.js';
import { FederationGetQueryService } from './get-query.service.js';
type FederatedRow = Record<string, unknown> & SourceTag;
function scopeDenyToHttpException(deny: {
readonly statusCode: 400 | 403;
readonly message: string;
}): HttpException {
const ErrorClass =
deny.statusCode === 400 ? FederationInvalidRequestError : FederationScopeViolationError;
return new HttpException(new ErrorClass(deny.message, deny).toEnvelope(), deny.statusCode);
}
@Controller('api/federation/v1/get')
@UseGuards(FederationAuthGuard)
export class GetController {
constructor(
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
@Inject(FederationGetQueryService) private readonly query: FederationGetQueryService,
) {}
@Post(':resource/:id')
async get(
@Param('resource') resource: string,
@Param('id') id: string,
@Req() request: FastifyRequest,
): Promise<FederationGetResponse<FederatedRow>> {
if (!request.federationContext) {
throw new HttpException(
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
401,
);
}
if (id.trim().length === 0) {
throw new HttpException(
new FederationInvalidRequestError('Federation get id must not be empty').toEnvelope(),
400,
);
}
const scopeResult = await this.scope.evaluateAccess({
context: request.federationContext,
resource,
requestedLimit: 1,
nativeRbac: this.query,
});
if (!scopeResult.allowed) {
throw scopeDenyToHttpException(scopeResult.deny);
}
const result = await this.query.get({ filter: scopeResult.filter, id });
if (result.status === 'not_found') {
throw new HttpException(
new FederationNotFoundError('Requested federation resource was not found').toEnvelope(),
404,
);
}
if (result.status === 'denied') {
throw new HttpException(
new FederationScopeViolationError(result.reason, {
resource,
id,
grantId: request.federationContext.grantId,
peerId: request.federationContext.peerId,
subjectUserId: request.federationContext.subjectUserId,
}).toEnvelope(),
403,
);
}
return { item: { ...result.item, _source: SOURCE_LOCAL } };
}
}
+5 -15
View File
@@ -1,7 +1,5 @@
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common'; import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue'; import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { SessionGCService } from './session-gc.service.js'; import { SessionGCService } from './session-gc.service.js';
import { REDIS } from './gc.tokens.js'; import { REDIS } from './gc.tokens.js';
@@ -11,17 +9,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
providers: [ providers: [
{ {
provide: GC_QUEUE_HANDLE, provide: GC_QUEUE_HANDLE,
useFactory: (config: MosaicConfig | null): QueueHandle | null => { useFactory: (): QueueHandle => {
// On Local tier there is no Redis — skip the ioredis connection entirely.
// The Valkey GC sweep is a no-op on Local (no session keys stored there).
if (config?.queue?.type === 'local') return null;
return createQueue(); return createQueue();
}, },
inject: [MOSAIC_CONFIG],
}, },
{ {
provide: REDIS, provide: REDIS,
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null, useFactory: (handle: QueueHandle) => handle.redis,
inject: [GC_QUEUE_HANDLE], inject: [GC_QUEUE_HANDLE],
}, },
SessionGCService, SessionGCService,
@@ -29,13 +23,9 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
exports: [SessionGCService], exports: [SessionGCService],
}) })
export class GCModule implements OnApplicationShutdown { export class GCModule implements OnApplicationShutdown {
constructor( constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
@Optional()
@Inject(GC_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
async onApplicationShutdown(): Promise<void> { async onApplicationShutdown(): Promise<void> {
await this.handle?.close().catch(() => {}); await this.handle.close().catch(() => {});
} }
} }
@@ -119,19 +119,6 @@ describe('SessionGCService', () => {
).resolves.toEqual({ allowed: true }); ).resolves.toEqual({ allowed: true });
}); });
it('collect() skips Valkey but still demotes only the requested session on local tier', async () => {
const localService = new SessionGCService(null, mockLogService as unknown as LogService);
const result = await localService.collect('local-session');
expect(result.sessionId).toBe('local-session');
expect(result.cleaned.valkeyKeys).toBeUndefined();
expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith(
'local-session',
expect.any(Date),
);
});
it('collect() returns sessionId in result', async () => { it('collect() returns sessionId in result', async () => {
const result = await service.collect('test-session-id'); const result = await service.collect('test-session-id');
expect(result.sessionId).toBe('test-session-id'); expect(result.sessionId).toBe('test-session-id');
+3 -10
View File
@@ -1,4 +1,4 @@
import { Inject, Injectable, Optional } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { QueueHandle } from '@mosaicstack/queue'; import type { QueueHandle } from '@mosaicstack/queue';
import type { LogService } from '@mosaicstack/log'; import type { LogService } from '@mosaicstack/log';
import { LOG_SERVICE } from '../log/log.tokens.js'; import { LOG_SERVICE } from '../log/log.tokens.js';
@@ -21,10 +21,7 @@ function escapeRedisGlobLiteral(value: string): string {
@Injectable() @Injectable()
export class SessionGCService { export class SessionGCService {
constructor( constructor(
// Local tier has no Redis; lifecycle cleanup still demotes this session's logs. @Inject(REDIS) private readonly redis: QueueHandle['redis'],
@Optional()
@Inject(REDIS)
private readonly redis: QueueHandle['redis'] | null,
@Inject(LOG_SERVICE) private readonly logService: LogService, @Inject(LOG_SERVICE) private readonly logService: LogService,
) {} ) {}
@@ -32,10 +29,8 @@ export class SessionGCService {
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking). * Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
* KEYS is avoided because it blocks the Valkey event loop for the full scan * KEYS is avoided because it blocks the Valkey event loop for the full scan
* duration, which can cause latency spikes under production key volumes. * duration, which can cause latency spikes under production key volumes.
* Returns an empty population on the Local tier where Redis is disabled.
*/ */
private async scanKeys(pattern: string): Promise<string[]> { private async scanKeys(pattern: string): Promise<string[]> {
if (!this.redis) return [];
const collected: string[] = []; const collected: string[] = [];
let cursor = '0'; let cursor = '0';
do { do {
@@ -52,15 +47,13 @@ export class SessionGCService {
async collect(sessionId: string): Promise<GCResult> { async collect(sessionId: string): Promise<GCResult> {
const result: GCResult = { sessionId, cleaned: {} }; const result: GCResult = { sessionId, cleaned: {} };
// 1. Valkey: delete all session-scoped keys (skipped on Local tier). // 1. Valkey: delete all session-scoped keys
if (this.redis) {
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`; const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
const valkeyKeys = await this.scanKeys(pattern); const valkeyKeys = await this.scanKeys(pattern);
if (valkeyKeys.length > 0) { if (valkeyKeys.length > 0) {
await this.redis.del(...valkeyKeys); await this.redis.del(...valkeyKeys);
result.cleaned.valkeyKeys = valkeyKeys.length; result.cleaned.valkeyKeys = valkeyKeys.length;
} }
}
// 2. PG: demote hot-tier agent logs for this session only. // 2. PG: demote hot-tier agent logs for this session only.
const cutoff = new Date(); const cutoff = new Date();
+3 -9
View File
@@ -18,7 +18,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
@Injectable() @Injectable()
export class CronService implements OnModuleInit, OnModuleDestroy { export class CronService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(CronService.name); private readonly logger = new Logger(CronService.name);
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = []; private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
constructor( constructor(
@Inject(SummarizationService) private readonly summarization: SummarizationService, @Inject(SummarizationService) private readonly summarization: SummarizationService,
@@ -26,12 +26,6 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
) {} ) {}
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
// Local tier deliberately has no BullMQ consumers or repeatable jobs.
if (!this.queueService.isEnabled()) {
this.logger.log('CronService: BullMQ disabled on local tier — no jobs will be scheduled');
return;
}
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
@@ -45,7 +39,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => { const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
await this.summarization.runSummarization(); await this.summarization.runSummarization();
}); });
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker); this.registeredWorkers.push(summarizationWorker);
// M6-005: Tier management repeatable job // M6-005: Tier management repeatable job
await this.queueService.addRepeatableJob( await this.queueService.addRepeatableJob(
@@ -57,7 +51,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => { const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
await this.summarization.runTierManagement(); await this.summarization.runTierManagement();
}); });
if (tierWorker) this.registeredWorkers.push(tierWorker); this.registeredWorkers.push(tierWorker);
// Retire any repeatable global GC schedule created by older deployments. // Retire any repeatable global GC schedule created by older deployments.
// Session cleanup is now triggered only by an authorized session lifecycle operation. // Session cleanup is now triggered only by an authorized session lifecycle operation.
@@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { MosaicConfig } from '@mosaicstack/config';
import { SystemOverrideService } from './system-override.service.js';
const localConfig = { queue: { type: 'local' } } as MosaicConfig;
describe('SystemOverrideService local tier', () => {
it('keeps ephemeral overrides isolated by tenant and user scope', async () => {
const service = new SystemOverrideService(localConfig);
const firstScope = { tenantId: 'tenant-a', userId: 'user-a' };
const secondScope = { tenantId: 'tenant-b', userId: 'user-b' };
await service.set('shared-session', 'first override', firstScope);
await service.set('shared-session', 'second override', secondScope);
await expect(service.get('shared-session', firstScope)).resolves.toBe('first override');
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
await service.clear('shared-session', firstScope);
await expect(service.get('shared-session', firstScope)).resolves.toBeNull();
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
});
});
@@ -1,8 +1,6 @@
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue'; import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import type { ActorTenantScope } from '../auth/session-scope.js'; import type { ActorTenantScope } from '../auth/session-scope.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) => const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
`${scope.tenantId}:${scope.userId}:${sessionId}`; `${scope.tenantId}:${scope.userId}:${sessionId}`;
@@ -17,45 +15,16 @@ interface OverrideFragment {
addedAt: number; addedAt: number;
} }
interface LocalOverrideEntry {
condensed: string;
fragments: OverrideFragment[];
}
@Injectable() @Injectable()
export class SystemOverrideService implements OnApplicationShutdown { export class SystemOverrideService {
private readonly logger = new Logger(SystemOverrideService.name); private readonly logger = new Logger(SystemOverrideService.name);
private readonly handle: QueueHandle | null; private readonly handle: QueueHandle;
/** Local-tier fallback, keyed by the same tenant/user/session scope as Redis. */
private readonly localStore = new Map<string, LocalOverrideEntry>();
constructor( constructor() {
@Optional() this.handle = createQueue();
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {
this.handle = this.mosaicConfig?.queue?.type === 'local' ? null : createQueue();
}
async onApplicationShutdown(): Promise<void> {
await this.handle?.close().catch(() => {});
} }
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> { async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
if (!this.handle) {
const key = scopedSessionId(sessionId, scope);
const entry = this.localStore.get(key) ?? { condensed: '', fragments: [] };
entry.fragments.push({ text: override, addedAt: Date.now() });
entry.condensed = await this.condenseOverrides(
entry.fragments.map((fragment) => fragment.text),
);
this.localStore.set(key, entry);
this.logger.debug(
`Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`,
);
return;
}
// Load existing fragments // Load existing fragments
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope)); const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
const fragments: OverrideFragment[] = existing const fragments: OverrideFragment[] = existing
@@ -85,14 +54,10 @@ export class SystemOverrideService implements OnApplicationShutdown {
} }
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> { async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
if (!this.handle) {
return this.localStore.get(scopedSessionId(sessionId, scope))?.condensed ?? null;
}
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope)); return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
} }
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> { async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
if (!this.handle) return;
const pipeline = this.handle.redis.pipeline(); const pipeline = this.handle.redis.pipeline();
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS); pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS); pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
@@ -100,11 +65,6 @@ export class SystemOverrideService implements OnApplicationShutdown {
} }
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> { async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
if (!this.handle) {
this.localStore.delete(scopedSessionId(sessionId, scope));
this.logger.debug(`Cleared system override for session ${sessionId} (local)`);
return;
}
await this.handle.redis.del( await this.handle.redis.del(
SESSION_SYSTEM_KEY(sessionId, scope), SESSION_SYSTEM_KEY(sessionId, scope),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
@@ -1,36 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import type { MosaicConfig } from '@mosaicstack/config';
import { QueueService } from './queue.service.js';
const localConfig = {
queue: { type: 'local' },
} as MosaicConfig;
describe('QueueService local tier', () => {
it('disables BullMQ and treats queue operations as local no-ops', async () => {
const service = new QueueService(null, localConfig);
expect(service.isEnabled()).toBe(false);
expect(service.getQueue('mosaic-test')).toBeNull();
expect(service.registerWorker('mosaic-test', vi.fn())).toBeNull();
await expect(
service.addRepeatableJob('mosaic-test', 'local-noop', {}, '* * * * *'),
).resolves.toBeUndefined();
await expect(service.removeRepeatableJobs('mosaic-test', 'local-noop')).resolves.toBe(0);
await expect(service.getHealthStatus()).resolves.toEqual({ queues: {}, healthy: true });
await expect(service.listJobs()).resolves.toEqual([]);
await expect(service.retryJob('mosaic-test__1')).resolves.toEqual({
ok: false,
message: 'BullMQ is disabled on local tier.',
});
await expect(service.pauseQueue('mosaic-test')).resolves.toEqual({
ok: false,
message: 'BullMQ is disabled on local tier.',
});
await expect(service.resumeQueue('mosaic-test')).resolves.toEqual({
ok: false,
message: 'BullMQ is disabled on local tier.',
});
});
});
+4 -63
View File
@@ -8,9 +8,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq'; import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
import type { LogService } from '@mosaicstack/log'; import type { LogService } from '@mosaicstack/log';
import type { MosaicConfig } from '@mosaicstack/config';
import { LOG_SERVICE } from '../log/log.tokens.js'; import { LOG_SERVICE } from '../log/log.tokens.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import type { JobDto, JobStatus } from './queue-admin.dto.js'; import type { JobDto, JobStatus } from './queue-admin.dto.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -110,43 +108,22 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
private readonly connection: ConnectionOptions; private readonly connection: ConnectionOptions;
private readonly queues = new Map<string, Queue<MosaicJobData>>(); private readonly queues = new Map<string, Queue<MosaicJobData>>();
private readonly workers = new Map<string, Worker<MosaicJobData>>(); private readonly workers = new Map<string, Worker<MosaicJobData>>();
/** False on Local tier — BullMQ/Redis operations become no-ops. */
private readonly enabled: boolean;
constructor( constructor(
@Optional() @Optional()
@Inject(LOG_SERVICE) @Inject(LOG_SERVICE)
private readonly logService: LogService | null, private readonly logService: LogService | null,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) { ) {
this.enabled = this.mosaicConfig?.queue?.type !== 'local'; this.connection = getConnection();
this.connection = this.enabled
? getConnection()
: ({ host: '127.0.0.1', port: 6380 } as ConnectionOptions);
}
/** Returns true when BullMQ/Redis is active (Standalone and Federated tiers). */
isEnabled(): boolean {
return this.enabled;
} }
onModuleInit(): void { onModuleInit(): void {
if (this.enabled) {
this.logger.log('QueueService initialised (BullMQ)'); this.logger.log('QueueService initialised (BullMQ)');
} else {
this.logger.log(
'QueueService: BullMQ disabled for local tier — no Redis connections will be opened',
);
}
} }
async onModuleDestroy(): Promise<void> { async onModuleDestroy(): Promise<void> {
if (this.enabled) {
await this.closeAll(); await this.closeAll();
} }
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Queue helpers // Queue helpers
@@ -154,10 +131,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/** /**
* Get or create a BullMQ Queue for the given queue name. * Get or create a BullMQ Queue for the given queue name.
* Returns null on Local tier where BullMQ is disabled.
*/ */
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null { getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> {
if (!this.enabled) return null;
let queue = this.queues.get(name) as Queue<T> | undefined; let queue = this.queues.get(name) as Queue<T> | undefined;
if (!queue) { if (!queue) {
queue = new Queue<T>(name, { connection: this.connection }); queue = new Queue<T>(name, { connection: this.connection });
@@ -169,7 +144,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/** /**
* Add a BullMQ repeatable job (cron-style). * Add a BullMQ repeatable job (cron-style).
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent. * Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
* No-op on Local tier.
*/ */
async addRepeatableJob<T extends MosaicJobData>( async addRepeatableJob<T extends MosaicJobData>(
queueName: string, queueName: string,
@@ -177,13 +151,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
data: T, data: T,
cronExpression: string, cronExpression: string,
): Promise<void> { ): Promise<void> {
if (!this.enabled) { const queue = this.getQueue<T>(queueName);
this.logger.debug(
`Skipping repeatable job "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
);
return;
}
const queue = this.getQueue<T>(queueName)!;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
await (queue as Queue<any>).add(jobName, data, { await (queue as Queue<any>).add(jobName, data, {
repeat: { pattern: cronExpression }, repeat: { pattern: cronExpression },
@@ -199,14 +167,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* safe retirement of previously registered system-wide jobs. * safe retirement of previously registered system-wide jobs.
*/ */
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> { async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
if (!this.enabled) {
this.logger.debug(
`Skipping repeatable-job removal for "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
);
return 0;
}
const queue = this.getQueue(queueName); const queue = this.getQueue(queueName);
if (!queue) return 0;
const jobs = await queue.getRepeatableJobs(); const jobs = await queue.getRepeatableJobs();
const matchingJobs = jobs.filter((job) => job.name === jobName); const matchingJobs = jobs.filter((job) => job.name === jobName);
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key))); await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
@@ -221,18 +182,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/** /**
* Register a Worker for the given queue name with error handling and * Register a Worker for the given queue name with error handling and
* exponential backoff. * exponential backoff.
* Returns null on Local tier where BullMQ is disabled.
*/ */
registerWorker<T extends MosaicJobData>( registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
queueName: string,
handler: JobHandler<T>,
): Worker<T> | null {
if (!this.enabled) {
this.logger.debug(
`Skipping worker registration for "${queueName}" (local tier — BullMQ disabled)`,
);
return null;
}
const worker = new Worker<T>( const worker = new Worker<T>(
queueName, queueName,
async (job) => { async (job) => {
@@ -289,12 +240,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/** /**
* Return queue health statistics for all managed queues. * Return queue health statistics for all managed queues.
* Returns an empty healthy result on Local tier.
*/ */
async getHealthStatus(): Promise<QueueHealthStatus> { async getHealthStatus(): Promise<QueueHealthStatus> {
if (!this.enabled) {
return { queues: {}, healthy: true };
}
const queues: QueueHealthStatus['queues'] = {}; const queues: QueueHealthStatus['queues'] = {};
let healthy = true; let healthy = true;
@@ -325,10 +272,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/** /**
* List jobs across all managed queues, optionally filtered by status. * List jobs across all managed queues, optionally filtered by status.
* BullMQ jobs are fetched by state type from each queue. * BullMQ jobs are fetched by state type from each queue.
* Returns empty array on Local tier.
*/ */
async listJobs(status?: JobStatus): Promise<JobDto[]> { async listJobs(status?: JobStatus): Promise<JobDto[]> {
if (!this.enabled) return [];
const jobs: JobDto[] = []; const jobs: JobDto[] = [];
const states: JobStatus[] = status const states: JobStatus[] = status
? [status] ? [status]
@@ -355,10 +300,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id"). * Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ * The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
* job IDs are not globally unique — they are scoped to their queue. * job IDs are not globally unique — they are scoped to their queue.
* Returns an error on Local tier.
*/ */
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> { async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const sep = compositeId.lastIndexOf('__'); const sep = compositeId.lastIndexOf('__');
if (sep === -1) { if (sep === -1) {
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' }; return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
@@ -390,7 +333,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Pause a queue by name. * Pause a queue by name.
*/ */
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> { async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const queue = this.queues.get(name); const queue = this.queues.get(name);
if (!queue) return { ok: false, message: `Queue "${name}" not found.` }; if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
await queue.pause(); await queue.pause();
@@ -402,7 +344,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Resume a paused queue by name. * Resume a paused queue by name.
*/ */
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> { async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const queue = this.queues.get(name); const queue = this.queues.get(name);
if (!queue) return { ok: false, message: `Queue "${name}" not found.` }; if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
await queue.resume(); await queue.resume();
+43
View File
@@ -79,6 +79,49 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
--- ---
## Per-estate durable agent working memory (#1051)
### Problem and objective
Agent and lane continuity currently accumulates as plain local files with no repository backing. The installer must make a private, per-estate `mosaic-brain` clone at `~/.mosaic` reproducible without authorizing cross-estate access or introducing an independent credential path.
### Normative requirements
1. `MB-REQ-01` (R1): Ensure the target estate's existing `mosaic-brain` can be cloned to `~/.mosaic`; repository creation and live access granting remain broker-mediated.
2. `MB-REQ-02` (R2/Q1): Derive estate and brain target from the configured target git host through the credential broker's estate registry. A second `brain_repo` authority and host-machine inference are forbidden; an unknown host fails closed with a named diagnosis.
3. `MB-REQ-03` (R3): Seat access is granted only through `mosaic cred`; callers must never resolve or read a token independently. Live grant verification is gated on MC-CRED-01 implementation.
4. `MB-REQ-04` (R4): The eventual live postcondition requires `~/.mosaic` to be a `main`-branch git repo with the expected remote and a seat-owned read/write round-trip. This live validation is gated on MC-CRED-01 implementation and cannot be replaced by a clone exit code.
5. `MB-REQ-05` (R5): The out-of-estate refusal control covers both Git and API resolver axes. Axis disagreement is `indeterminate` failure, never permission; contract tests bind to the broker's four terminal classes and stable reason codes.
6. `MB-REQ-06` (R6): The brain skeleton excludes `*.token`, `*.key`, `*.pem`, `.env`, and `credentials.json`; credentials remain broker-owned and no error path may print secret material. Arbitrary legacy content is never auto-published from a heuristic denylist: an approved content scanner must bind approval to the exact source snapshot, otherwise the item is retained and reported.
7. `MB-REQ-07` (R7): Detect existing local lane directories and seat state files, publish approved snapshots into the durable layout without overwrite or deletion, and explicitly report every detected item that cannot be migrated. Automatic source deletion is parked until command-scoped identity propagation and the required clean audit; retained sources are always reported. Lane findings are append-only; `board/` has a named single writer; writes push immediately rather than on a timer.
8. `MB-REQ-08` (R8): `mosaic doctor` reports missing clone, wrong remote, incomplete write-access evidence, and uncommitted local state. `--fix` repairs the first three only through the approved installer/broker path; it never hand-rolls credential resolution.
9. `MB-REQ-09`: Retention is ownership-first and archive-only. Every retained artifact requires a named durable owner; absent or non-durable ownership leaves the gate open and blocking. Age and size never authorize deletion.
10. `MB-REQ-10`: Brain provisioning occupies canonical installer P7 only after the applicable P5 credential postcondition commits; canonical phase numbers are unchanged.
### Current delivery slice
In scope now: estate derivation, secret exclusion, non-destructive migration, doctor reporting/repair orchestration, and red-first tests over all four credential-contract terminal classes. Live grant and live read/write round-trip evidence remain explicitly gated on the working MC-CRED-01 broker and must not be mocked or replaced by independent token lookup.
### Acceptance criteria
1. `AC-MB-01`: Contract tests observe RED before implementation and then distinguish `ok/0`, `refused/10`, `error/20`, and `indeterminate/30`, preserving v1.5 diagnoses including refused `provider-identity-mismatch`/`credential-rejected` and indeterminate `identity-not-measured`/`provider-unavailable`. A scope-forbidden `/user` result with confirmed in-scope repository capability is never represented as a dead credential. `identity-not-found` remains reserved for a future visibility-authorized inventory operation and is not an expected `validate` result.
2. `AC-MB-02`: Estate resolution uses the configured target git host and one registry; unknown, mismatched, and host-machine-derived inputs fail closed.
3. `AC-MB-03`: A clean fixture contains the required layout and exact secret exclusions; filename-, content-, binary-, and size-based secret controls remain outside Git without their values appearing in output. Without an approved scanner, even benign legacy content is retained and reported rather than auto-published.
4. `AC-MB-04`: Migration publishes approved lane-durable and seat-state snapshots into collision-safe archive/ledger paths, retains and reports every source, never overwrites an existing finding, and never deletes by age/size.
5. `AC-MB-05`: Doctor detects all four R8 defect classes; `--fix` repairs eligible classes through the approved P7/broker seam and leaves unresolved credential-dependent states visible.
6. `AC-MB-06`: Git-axis and API-axis refusal must both be authoritative `refused` outcomes with matching stable reason codes; any disagreement yields `indeterminate`.
7. `AC-MB-07`: Independent code review and security review pass at the exact head, and HOMELAB Woodpecker instance `mosaic` is terminal green before integration.
8. `AC-MB-08`: After reviewed merge to `main`, report only **believed-fixed, pending jarvis validation**; issue #1051 remains open until W-jarvis validates the installed result.
### Constraints and risks
- MC-CRED-01 contract v1.5 is the caller boundary; no independent credential/token lookup is permitted. Identity is established from governed mint-time binding and provider evidence when measurable, never a credential filename. Runtime validation does not widen a least-privilege token merely to make `/user` observable.
- C1 owns installer phase sequencing. This slice consumes P5/P7 ordering without renumbering or duplicating the phase machine.
- Lane content is findings, so last-writer-wins is data loss. Append-only names and explicit collision handling are mandatory.
- A created-but-empty brain beside unbacked local doctrine fails the objective; migration is a primary acceptance gate.
---
## Compaction Refresh Trust Lifecycle (M1, #827#830) ## Compaction Refresh Trust Lifecycle (M1, #827#830)
### Problem and objective ### Problem and objective
@@ -1,63 +0,0 @@
# npm `@next` prerelease lane
Status: **IMPLEMENTED**
## Current behavior
`tools/install.sh --next` provides the prerelease integration lane for the permanent `next` branch.
The lane is fast-by-default:
1. Install framework files from the `next` source archive.
2. Resolve the Gitea npm registry `next` dist-tag for the globally installed packages:
```bash
npm view @mosaicstack/gateway@next version
npm view @mosaicstack/mosaic@next version
```
3. Require both resolved versions to share the same `next.<pipeline>` suffix, then install the exact resolved versions.
4. If either `@next` package is missing, unreachable, mismatched, or fails to install, fall back to the source-build path at `next`.
`--next` never hard-fails solely because the prerelease npm dist-tag is unavailable.
## Published packages
The `next` publish pipeline publishes non-private `@mosaicstack/*` packages to the Mosaic Gitea npm registry:
```text
https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
```
Observed `next` dist-tags after enabling the pipeline:
```text
@mosaicstack/mosaic@next -> 0.0.49-next.1633
@mosaicstack/gateway@next -> 0.0.7-next.1633
```
The gateway also publishes a Docker image as `gateway:sha-<short>` on `next` merges. The installer fast path uses the npm gateway package when available; the Docker image is for deployed gateway/runtime harness flows.
## Explicit source lanes
Source builds remain available and are still the authority for explicit ref validation:
- `--dev` always builds from source.
- `--ref <ref>` / `MOSAIC_REF=<ref>` wins over `--next` and uses the source path for that exact ref.
## Pipeline shape
1. Trigger on `next` merges.
2. Compute the next prerelease version from the upcoming stable version plus the Woodpecker pipeline number (`<target-stable>-next.<CI_PIPELINE_NUMBER>`).
3. Build and publish non-private packages in CI.
4. Publish to the Mosaic Gitea npm registry with dist-tag `next`.
5. Keep `latest` untouched; only main/release promotion can update `latest`.
6. Publish gateway Docker images from `next` as `gateway:sha-<short>` only.
## Guardrails
- `@next` is mutable prerelease convenience, not a deployment pin.
- Stable installs continue to use `@latest`.
- Contributor validation remains available through `--dev --ref <branch>`.
- Pipeline output traces every prerelease package back to the source commit on `next`.
- The installer falls back to source rather than hard-failing on prerelease registry issues.
-11
View File
@@ -195,17 +195,6 @@ pnpm format:check && pnpm typecheck && pnpm lint
A pre-push hook enforces this mechanically. A pre-push hook enforces this mechanically.
### CI Publish Channels
Woodpecker `.woodpecker/publish.yml` keeps stable and integration-line artifacts separate:
| Source | npm packages | Gateway image |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `main` push/manual or release tag | committed package versions published to Gitea npm without changing the dist-tag workflow | `gateway:sha-<short>` plus `gateway:latest` on `main`, and the release tag on tag events |
| `next` push/manual | CI-computed prereleases, `<target-stable>-next.<CI_PIPELINE_NUMBER>`, published with `npm publish --tag next` | `gateway:sha-<short>` only |
`next` never publishes npm `latest` or Docker `latest`. The next npm publish step verifies that `@mosaicstack/mosaic@next` resolves to the computed prerelease before the pipeline can pass.
--- ---
## Adding New Agent Tools ## Adding New Agent Tools
+2 -12
View File
@@ -175,18 +175,8 @@ Or use the direct URL:
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh) bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
``` ```
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
non-interactive use:
Install lanes:
| Lane | Command | Source |
| ------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | npm `@mosaicstack/mosaic@latest` + `main` |
| Prerelease integration | `bash tools/install.sh --next` | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | Build-from-source at the requested ref |
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` still wins and uses the source path.
Flags for non-interactive use:
```bash ```bash
--yes # Accept all defaults --yes # Accept all defaults
@@ -0,0 +1,71 @@
# #1019 — Zero-timeout queue-guard harness race
- **Issue:** #1019 (parent status remains `believed-fixed, pending jarvis validation`; do not close)
- **Branch:** `fix/1019-ci-queue-timeout-harness`
- **Owner:** `be-coder-08`
- **Base:** `origin/main` at `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`
- **Charter:** `/home/hermes/agent-work/tl-mosaic/CHARTER-1019-HARNESS-FIX.md`
## Objective
Make `test-ci-queue-wait-tristate.sh` deterministic without changing any asserted outcome. Remove the indiscriminate zero-timeout race, require every status-classification case to prove the provider was observed, and prove the harness-controlled virtual clock is active.
## Scope
- In scope: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` only, plus this evidence scratchpad.
- Out of scope: guard parsers, D2/D3 behavior, installer/reseed staleness, PR #1060, and issue closure.
## Acceptance criteria
1. RED deterministically reproduces deadline pre-emption before the provider call.
2. Every case that intends status classification positively proves provider observation.
3. Pending observes `pending` before deterministic virtual-time expiration.
4. The virtual clock has a positive interception control; a broken-clock mutant makes the suite red.
5. The exact CI-base image passes the final harness repeatedly with zero failures.
6. Baseline gates, independent code/security review, exact-head CI, and coordinator-authorized squash merge pass.
## Plan
1. Add deterministic RED instrumentation for the known merge/provider-unreachable pre-emption.
2. Replace global `-t 0` with a nonzero timeout interpreted under an event-driven virtual clock; stub sleep without wall waiting.
3. Add provider-observation and virtual-clock positive controls without changing outcome assertions.
4. Run focused shell checks, repeat in exact CI-base image, baseline gates, and independent reviews.
5. Commit with both identity layers, queue-guard plus direct Woodpecker terminal-state verification, push, self-post PR, verify poster/head/CI, obtain coordinator merge authorization, then squash merge without closing #1019.
## Budget
- No explicit token cap supplied. Keep scope to one harness file and one scratchpad; stop/report at the charter's 60% context gate.
## Evidence
- RED, deterministic pre-provider expiry: `evidence/1019-harness-fix/red-pre-provider-expiry.log` — rc 1; merge/provider-unreachable got rc 124 instead of 75, omitted CANNOT_ASSERT, did not observe the status provider, and wrote no additional audit record (four named failures).
- GREEN host focused harness: `evidence/1019-harness-fix/green-host.log` — rc 0, all outcome classes passed.
- Load-bearing clock negative control: a temporary same-directory mutant replaced the virtual `date` body with `/bin/date`; `evidence/1019-harness-fix/red-clock-not-intercepted.log` — rc 1 with named `virtual clock interception did not run` failures. The mutant file was removed after the run.
- Exact CI-base repeat: `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`, repository mounted read-only, harness work under container `/tmp`; `evidence/1019-harness-fix/ci-image-repeat/summary.log`**100 pass / 0 fail / 100 total**.
- Synchronization design: provider-status observation creates the event marker; virtual time is 1000 before the event and 1002 afterward. Pending alone reaches the stubbed no-op sleep and a post-observation deadline check. `-t 1` is uniquely load-bearing because removing it restores the 900-second default deadline at virtual time 1900, which 1002 does not cross. The numeric timeout is subject semantics under virtual time, not a wall-clock synchronization duration.
## Review remediation — semantic timeout vs. liveness bound
Security review found that virtual time remained at 1000 forever before provider observation and stubbed sleep never waited. A regression looping before the status endpoint—or blocking in the first provider call—therefore could prevent `run_guard` from returning, so the post-return provider assertion could never fire.
**General rule:** A timeout usually serves two purposes: semantics and liveness. Removing wall time from semantic synchronization can silently remove the only independent hang bound. Preserve deterministic virtual time for subject semantics, but provide a separately implemented real-clock liveness watchdog and prove that watchdog fires.
Remediation:
- Every guard subject invocation is launched by absolute `/usr/bin/python3` in a new session. Python's internal monotonic `wait(timeout=...)` provides real-clock liveness independently of PATH; expiry kills the entire isolated process group, so neither PATH-front shims nor a blocked provider descendant can retain the capture pipe.
- Watchdog expiry returns distinct harness rc 90 plus `FAIL HANG watchdog`, separate from subject timeout rc 124.
- A first attempt using absolute `/usr/bin/timeout -s KILL` passed on GNU coreutils but failed in the exact Alpine CI-base image: BusyBox killed the immediate wrapper while the guard/provider descendants survived and retained the command-substitution pipe. The process-group kill is therefore required behavior, not portability polish.
- A committed positive control hangs the branch-provider stub before the status endpoint. It must terminate through the watchdog, emit the hang-specific diagnostic, return rc 90, and prove the status provider was never reached.
- RED before remediation: a temporary ordinary-success mutant hung before provider observation; only an external control could kill the suite (rc 137), and there was no internal hang-specific diagnostic (`red-watchdog-absent.log`).
- The watchdog mutant/control is load-bearing: removing the internal watchdog leaves the control unable to produce its required rc 90 and diagnostic.
Post-review evidence:
- Host focused harness with process-group watchdog: rc 0 (`green-watchdog-process-group-host.log`).
- Exact Alpine CI-base focused harness with process-group watchdog: rc 0 (`green-watchdog-ci-image.log`).
- Hanging ordinary-success mutant: suite rc 1; success returned rc 90, emitted `FAIL HANG watchdog`, and loudly reported that provider/clock observation did not occur (`red-watchdog-fires.log`).
- Removed-`-t 1` mutant: suite rc 1; pending was terminated by the watchdog instead of producing `ASSERTED_NOT_READY`, proving the explicit timeout is load-bearing (`red-timeout-argument-removed.log`).
## 60% context hold
Stopped before baseline/review/commit as required by the charter. Remaining: inspect final diff, shell/static/baseline gates, independent code/security review, remediation if any, identity-bound commit/trailer verification, mandatory queue guard plus direct terminal Woodpecker `mosaic` enumeration, push, self-posted PR/provider poster read-back, exact-head terminal-green CI, coordinator merge authorization, squash merge, main CI verification, and leave #1019 unclosed as `believed-fixed, pending jarvis validation`.
@@ -0,0 +1,115 @@
# #1051 — per-estate mosaic-brain installer
Last updated: 2026-08-05
## Objective
Codify estate-derived, repository-backed `~/.mosaic` support with secret exclusions, non-destructive migration, doctor diagnostics/fixes, and credential-contract terminal-class handling. Live broker grants and live read/write round-trips remain gated on MC-CRED-01.
## Sources and bindings
- Provider issue: HOMELAB `git.mosaicstack.dev`, `GET /api/v1/repos/mosaicstack/stack/issues/1051`, `application/json;charset=utf-8`.
- Issue requirements: R1R8 read directly on 2026-08-05.
- MC-CRED caller contract: v1.5, SHA-256 `4cecba3386b37431d4a075205c6dfe43555c7673922fed61b84f43cac1a6ae92` at the 2026-08-05 re-derivation. Earlier moving bindings were v1.5 `710d22d61a93a4b9c70fc55506a023a675a110417fa7a6e72dc051c0d9fe8237`, v1.4 `27f20158561ae8292f3bfc926b5e97f398de93db6a1cf65fcc215d08811d39af`/`d12ad4595b7aef078e392988a07ab5cb00244440775c9c733dc825746d7ac67b`, and v1.3 `8cfa4853d2b0b0e8cc9e792fa8411310e16d7704c06e0af9d9a57155131d8086`.
- Fleet doctrine: SHA-256 `026b43322e0551ef15b646a9f30d3a6aef58c662a810b732be2a03b1ecf7d36e` at intake.
- Intake base was HOMELAB provider `next` = `4df478cdd150fdf8d52ea109f02ade5d85017acd`; `main` = `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`. On 2026-08-05 `mos-claude` ruled that L0 trunk-based gate 15 requires all three lanes to retarget to `main`; `next` remains a non-merging integration branch. Never weaken or patch `pr-merge.sh`.
## Scope
### In now
- R2/Q1 target-host estate derivation using one registry.
- R5 both-axis refusal parity and disagreement failure.
- R6 exact secret exclusions and no secret-bearing diagnostics.
- R7 detection plus non-destructive, collision-safe migration/reporting.
- R8 doctor checks and approved-seam fix orchestration.
- Red-first tests over all four contract terminal classes and stable reason codes.
### Gated / excluded
- R3 live grant: waits for working MC-CRED-01.
- R4 live seat-owned read/write round-trip: waits for working MC-CRED-01.
- No independent token lookup, grant helper, or shared-credential fallback.
- No phase renumbering; C1 owns the phase machine and provides the P5→P7 seam.
- No age/size reaping or deletion.
## Owner authority ruling and resolver seam
- Binding addendum: `/home/hermes/agent-work/tl-mosaic/CHARTER-MB-BRAIN-01-ADDENDUM.md`; re-read after compaction.
- HOMELAB durable lane-archive owner and user-namespace brain owner are the human provider account selected by local estate policy (operator ruling: `jason.woltje`) with a required GLPI queue as the standing remediation process. The brain target is therefore `<policy-owner>/mosaic-brain` on the estate host, not `<installer-source-org>/mosaic-brain`. Framework source remains operator-agnostic: the actual login and queue are local policy, not hardcoded open-source context.
- Provider lookup is anonymous because the ruled owner is public. It requires exact allowlisted login plus a same-invocation public known-good control, private 404 control, and generated absent 404 control. It sends no Authorization header and never widens token scope.
- Provider `active` is deliberately ignored: non-admin reads return false for demonstrably active accounts. Resolvability + exact login + public visibility are the gate.
- Private and absent principals both return anonymous 404. The fail-closed reason is `owner-not-resolvable`, never owner-not-found.
- Caller `owner` strings and `validated=true` are ignored. Migration consumes only an injected source-of-truth resolver result. Owner grammar is NFKC-stable, ASCII allowlisted, exact-policy matched, and mission-seat class is excluded.
## Plan
1. Pre-register acceptance tests and observe each requirement RED for its own missing behavior.
2. Commit the red tests before implementation.
3. Implement a narrow brain provisioning/doctor helper that consumes broker JSON outcomes and the shared estate registry without credential resolution.
4. Implement safe migration and exact brain skeleton/ignore policy.
5. Integrate the helper into C1's P7 seam and `mosaic doctor` after C1 lands/rebase.
6. Run focused, package, installer, lint, typecheck, format, and situational security tests.
7. Run independent code and security reviews in parallel; remediate and re-review.
8. Push after HOMELAB queue guard, open the reviewed PR to `main`, and preserve merge order C1 → MC-CRED → MB-BRAIN. Do not modify the merge guard; `next` is non-merging integration only.
9. Re-take CI measurement at the rebased exact head; do not rework code solely because base evidence moved.
## Acceptance interpretation registered before results
- `ok/0`: complete authoritative evidence only.
- `refused/10`: complete authoritative denial only.
- `error/20`: local contract/control failure; never reinterpret as denial.
- `indeterminate/30`: incomplete/disagreeing evidence; fail closed, never resolve permissively.
- Both Git and API axes must return authoritative `refused` with the same stable reason code for R5. Any axis disagreement is `indeterminate`. A provider `/user` login mismatch is first-class `provider-identity-mismatch`; credential filenames never establish principal identity.
- Migration publication requires the durable object to contain the approved snapshot and no overwrite. Automatic path-based source deletion is parked; every source is retained and reported.
- Secret exclusion is tested through exact ignore rules, nested secret-shaped paths, bounded UTF-8 content controls, and an approved-scanner gate bound to the exact source snapshot. Without an approved scanner, even benign content is retained and reported rather than committed.
## Budget
No explicit token ceiling was supplied. Working cap: 55K tokens for implementation/review and 3 focused remediation attempts per failure class. Reduce optional refactoring and documentation breadth before touching required acceptance scope.
## Risks
- C1 and MC-CRED branches have not merged into `main`; integration edits must wait for their exact interfaces or be confined to stable contract seams.
- A broker runtime test before MC-CRED lands would either fail for an irrelevant reason or pressure a hand-rolled workaround; contract fixtures are allowed, live capability claims are not.
- Migration can lose data through overwrite, cross-device move failure, or partial copy. Implementation must stage, verify resulting bytes, and retain/report source on incomplete transfer.
- `~/.mosaic` is a git repo, while current working state may live under multiple local roots; detection must be explicit and cannot treat age/size as ownership.
## Progress / evidence
- [x] Charter receipt accepted by `tl-mosaic`.
- [x] Issue #1051 R1R8 read directly from provider.
- [x] Contract re-derived through v1.3, moving v1.4, and v1.5 before R5 integration. v1.5 separates in-scope repository capability from `/user` identity measurement: 401 is `credential-rejected`/refused, 403/404 may become `identity-not-measured` only after in-scope capability succeeds, and 200 login mismatch is refused. `identity-not-found` is not reachable from `validate`.
- [x] C1 P5→P7 seam receipt read; no brain implementation is in C1.
- [x] RED acceptance set committed at `cf11c6c86abae073d8b02b4014cd5447ba67f12a`; author and committer read back as `be-coder-07` and branch reachability was independently verified by `tl-mosaic`.
- [x] Moving-contract REDs observed independently for v1.4 mismatch, R8 prerequisite ordering, owner resolver seam/allowlist, tracked skeleton/no-follow behavior, runtime observation/publication, and provider owner resolution.
- [x] Focused implementation includes secure migration, v1.5 write-differential/subject binding, production Git+API refusal parity, provider-backed durable owner resolution that ignores non-admin `active`, required GLPI standing-process policy, P7 provision orchestration, an internal installer command, and installed `mosaic doctor` wiring. Latest focused result: 97/97 (secure config 4, store 45, runtime 19, owner resolver 16, provision 5, provision command 3, installed doctor 5).
- [x] MC-CRED added the required canonical reverse registry seam `ParsedCredentialEstateRegistry.resolveByHost()`; the 32-line permissive shim was removed. After exact-head CI proved the cross-PR source dependency was absent, the provider-fetched canonical registry implementation, DTO dependencies, and registry tests were tracked byte-for-byte on this branch so a fresh checkout validates the real seam rather than a stub. A later rebase onto merged MC-CRED should recognize those identical files as upstream.
- [x] Identity gotcha measured: inline `MOSAIC_GIT_IDENTITY=be-coder-07` controls credential resolution but does not override `user.name`/`user.email` inherited from the linked worktree common-dir config (`coder-mos1`). The first local P7 RED commit was immediately amended before push with command-scoped `GIT_AUTHOR_*` + `GIT_COMMITTER_*`; resulting author and committer both read back as `be-coder-07`. Every subsequent authoring command must carry both identity sets and be verified.
- [x] R6 migration reports filename- or content-secret-shaped files without copying them; arbitrary legacy content requires an approved scanner bound to the exact source snapshot, and production currently retains/reports when no approved scanner is configured. `.gitignore` is canonical allowlisted content only: an existing noncanonical regular file fails closed and is never merged into publication. Symlinked `.gitignore`, layout directories, and nested migration destinations fail closed; a dirty checkout blocks provisioning before skeleton publication. The brain root is principal-owned mode `0700` before clone and after clone, all memory-bearing layout directories are mode `0700` even under umask `0022`, and doctor reports owner-accessible roots as hard unsafe findings.
- [x] Provider owner lookup uses manual redirect handling, a five-second abort signal, strict JSON content type/shape, and an incrementally enforced 256 KiB response ceiling.
- [x] Security-critical owner policy/registry reads have direct controls for principal UID ownership, file/ancestor permissions, and descriptor-safe regular-file reads.
- [x] Automatic source deletion is parked per the shared-Git-identity governance ruling; remotely reachable snapshots still leave and report every source.
- [x] Multi-host push-on-write uses an isolated temporary Git index populated from approved in-memory blobs rather than pathname re-reads, verifies each committed blob ID, the exact changed-path allowlist, and both author/committer trailers before push, then reconciles only approved paths into the real checkout index. Real-repository controls prove a clean checkout remains clean, a concurrent non-fast-forward fetch/rebase/push remains clean and preserves both findings, destination-path substitution cannot change committed bytes, and unrelated pre-staged secret-shaped content remains staged but never enters the published commit.
- [x] Doctor Git observations preserve three states: `clean`, `dirty`, and `unmeasurable`; failed remote, branch, or status measurements emit hard `brain-git-state-indeterminate` findings rather than mismatch or ready. The boolean-literal guard sweep covered all MB-BRAIN production files in the 20-file PR population: its only remaining `=== false` guard is the non-nullable `isAbsolute()` predicate; no nullable boolean measurement guards remain.
- [x] Author-run Review 10 and focused 88/88 evidence were declared void when blocker fixes changed the head; neither is an independent gate pass.
- [ ] Installer shell P7 invocation after C1 + MC-CRED integration; production command is registered but the C1 shell has not yet called it.
- [ ] Implementation green on merged dependency base.
- [ ] Independent code review.
- [ ] Independent security review.
- [ ] HOMELAB CI terminal green at exact head.
- [ ] Reviewed PR retargeted to `main` after C1 and MC-CRED; `next` remains non-merging integration only.
## Exact-head CI dependency remediation
- Pipeline `#2222` at `a50b5a6b` ran the sole pull-request-eligible workflow (`ci`, 1/3 defined workflows) and failed `typecheck` with two `TS2307` errors before lint, format, or tests could execute.
- History establishes that the imports are intentional: commit `2451c2f` introduced both consumers, while the contemporaneous registry-seam report explicitly called the local 32-line implementation a disposable scaffold and required MC-CRED's canonical parser. This was a deliberate cross-PR dependency, not a wrong import or forgotten shim add.
- RED-first root typecheck reproduced the two missing-module errors. The fix tracks the provider-fetched MC-CRED registry, its two DTO dependencies, its unit test, and the result DTO dependency. Three DTO files remain byte-identical to `fbff4ffa`; author review found that the canonical parser accepted a trailing-slash origin which MB-BRAIN consumers concatenate into double-slash URLs, so the parser and test are intentionally hardened here pending propagation to MC-CRED.
- R7 removed the tracked registry module and root typecheck returned RED with three missing-module errors (the two production consumers plus the registry unit test); restoring the same SHA-256 returned typecheck to 45/45 tasks.
- With the suppressing typecheck failure removed, lint ran 25/25 tasks and formatting passed. Full tests actually ran: 1,607/1,614 passed; the seven failures are the four pre-registered P7 integration tests intentionally held for C1/MC-CRED integration plus the three previously disclosed ambient update-banner CLI smoke failures. No test was weakened. Build ran 25/25 tasks.
- Author review's trailing-slash finding was reproduced RED (`https://git.example.invalid/` accepted), then fixed by requiring the configured source to equal `URL.origin`; the control reran GREEN. Security review reported risk `none` with zero findings; final code re-review remains required after remediation.
- This is a second instance of the `#1068` suppression class: an early integrity failure prevented every downstream stage carrying behavioral evidence from running while the workflow's aggregate failure looked like a completed check. Workflow reordering remains `#1068` scope and is not changed here.
## Completion language
After reviewed merge to `main`, only: **believed-fixed, pending jarvis validation**. Issue #1051 remains open until W-jarvis validates the installed result.
@@ -1,38 +0,0 @@
# Scratchpad — FED-M3-06 get verb
## Objective
Implement `POST /api/federation/v1/get/:resource/:id` for M3 inbound federation reads.
## Scope
- `apps/gateway/src/federation/server/verbs/get.controller.ts`
- `apps/gateway/src/federation/server/verbs/get-query.service.ts`
- Unit coverage for controller pipeline + query service RBAC guardrails
- Register controller/service in `FederationModule`
## Plan
1. Mirror the list verb pipeline: `FederationAuthGuard``FederationScopeService` → read-only query service.
2. Return one `_source: "local"` tagged item on success.
3. Return federation error envelopes:
- `404 not_found` when the resource id does not exist.
- `403 scope_violation` when the row exists but falls outside native RBAC/scope intersection.
- `400 invalid_request` for malformed ids/scope requests.
4. Keep read audit persistence deferred to M4; no body or response persistence in M3.
## Verification Evidence
- Rebased onto `origin/main` at `86e106fcc9a1dfa3a18f7846bb477be128794aad` after M3-05 merged; resolved `FederationModule` by registering both list and get verb controllers/services.
- Review-change coverage added for comment 15971:
- get note access now requires subject ownership AND authorized mission intersection.
- missing federation context returns structured `401 unauthorized` envelope.
- unsupported get resources fail closed with structured denial.
- PGlite regressions cover cross-user note exclusion and subject-note unauthorized-mission exclusion.
- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/verbs/__tests__/get.controller.spec.ts src/federation/server/verbs/__tests__/get-query.service.spec.ts` — pass (2 files / 17 tests; re-run after review changes).
- `pnpm --filter @mosaicstack/gateway build` — pass (re-run after review changes).
- `pnpm build` — pass (23 successful tasks before review changes).
- `pnpm typecheck` — pass (41 successful tasks; re-run after review changes).
- `pnpm lint` — pass (23 successful tasks; re-run after review changes).
- `pnpm format:check` — pass (re-run after review changes).
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings after review changes.
@@ -1,82 +0,0 @@
# B1 / @next Durable Publish Pipeline — Design
## Objective
Make `next` a durable integration line that publishes the artifacts required by downstream federation boot tests without manual builds.
Every merge to `next` publishes:
1. **npm prerelease packages** to the Gitea npm registry with dist-tag `next`.
2. **Gateway container image** tagged only as `gateway:sha-<short>`.
The existing stable release behavior remains isolated to `main` / tags.
## Registry verification
Target registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`.
Pre-implementation checks:
- `npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json` returned a dist-tags object (`latest: 0.0.48`).
- `npm view @mosaicstack/mosaic@latest version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/` resolved `0.0.48`.
- `@next` currently returns 404 because no `next` dist-tag exists yet; this is expected before the first next prerelease publish.
Pipeline design includes a post-publish verification that `npm view @mosaicstack/mosaic@next version` resolves to the exact CI-computed prerelease version. If Gitea fails to honor the `next` dist-tag, the pipeline fails closed.
## Version scheme
The prerelease version is computed at publish time only; no `package.json` version changes are committed.
For each non-private `@mosaicstack/*` package:
```text
<target-stable>-next.<CI_PIPELINE_NUMBER>
```
Where:
- `CI_PIPELINE_NUMBER` is Woodpecker's monotonic pipeline number.
- `target-stable` is the package's current committed stable version with the patch component incremented.
- Example: `@mosaicstack/mosaic` `0.0.48` publishes as `0.0.49-next.1626`.
- Example: `@mosaicstack/gateway` `0.0.6` publishes as `0.0.7-next.1626`.
Rationale:
- npm semver sorts `0.0.49-next.1627` above `0.0.49-next.1626`.
- The prerelease does not overtake the future stable `0.0.49`.
- The monotonic pipeline number avoids conflicts across repeated `next` merges.
## Branch and tag guardrails
| Pipeline path | Branch/event | Publishes | Forbidden |
| --------------------- | ------------------------------ | ------------------------------------------------------- | ---------------------- |
| stable npm publish | `main` push/manual or tag | package versions already committed in package manifests | `@next` dist-tag |
| next npm publish | `next` push/manual only | CI-computed prereleases with `--tag next` | `latest` dist-tag |
| gateway image | `main` push/manual or tag | `sha-<short>` + `latest` on main + tag on tag events | next prerelease npm |
| gateway image | `next` push/manual only | `sha-<short>` only | `latest` |
| appservice/web images | `main` push/manual or tag only | existing stable image behavior | next image publication |
The pipeline has explicit branch checks inside the publish commands as a second fail-closed layer beyond Woodpecker `when` clauses.
## Implementation plan
1. Widen `.woodpecker/publish.yml` top-level `when` to include `next` so the publish pipeline runs on next merges.
2. Keep existing `publish-npm` on `main` / tags only.
3. Add `publish-next-npm` for `next` push/manual only:
- configure Gitea npm auth from existing `gitea_token` secret as `NPM_TOKEN`;
- preflight registry dist-tag metadata;
- compute prerelease versions in CI by temporarily editing package manifests in the workspace;
- run `pnpm publish ... --tag next` against non-private `@mosaicstack/*` packages;
- verify `@mosaicstack/mosaic@next` resolves to the computed version.
4. Split image `when` anchors:
- `image_build_when` includes `next` and is used by `build-gateway`;
- `main_image_build_when` keeps appservice/web on main/tags only.
5. Keep gateway next image destinations to `sha-<short>` only; no `latest` on next.
## Risk controls
- Auth/registry failures are fatal.
- No manual image build/push path is introduced.
- No production `latest` tags are touched from `next`.
- No `@latest` npm dist-tags are touched from `next`.
- All changes live in CI config and docs; no runtime source behavior changes.
-34
View File
@@ -1,34 +0,0 @@
# B2 — Fresh-install skills sync path
## Problem
Greenfield wizard on `next` reported:
```text
Skills sync script not found at ~/.config/mosaic/bin/mosaic-sync-skills
Skills: install failed
```
## Diagnosis
The framework install migration removed the legacy `~/.config/mosaic/bin/` directory and now installs framework helper scripts under:
```text
~/.config/mosaic/tools/_scripts/
```
`packages/mosaic/src/stages/finalize.ts` still resolved wizard helper scripts from `mosaicHome/bin`, so wizard-selected skills failed even though `mosaic-sync-skills` was present in the current framework layout.
## Fix
- Resolve framework helper scripts through `tools/_scripts/<name>` first.
- Keep a legacy `bin/<name>` fallback for pre-migration installs.
- Point missing-script warnings at the current `tools/_scripts` layout.
- Update the finalize skills test fixture to model the fresh framework layout.
- Update framework README examples from legacy `bin/` helper paths to `tools/_scripts/`.
## Verification
- Unit: `pnpm --filter @mosaicstack/mosaic test -- finalize-skills`
- Gates: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build`
- Fresh path: ran `packages/mosaic/framework/install.sh` with a temp `MOSAIC_HOME` and `MOSAIC_SYNC_ONLY=1`; verified `tools/_scripts/mosaic-sync-skills` exists, legacy `bin/mosaic-sync-skills` does not, and the script installs a selected fake `lint` skill into Mosaic + Pi runtime skill directories.
@@ -1,36 +0,0 @@
# B3 — Wizard completion ordering
## Problem
The wizard printed the success summary / `Mosaic is ready.` during `finalizeStage`, before the gateway configuration stage had completed its daemon health check. If the gateway health gate later failed, the user could see a success claim followed by a gateway failure.
## Diagnosis
`finalizeStage` handled both mutation work and terminal success messaging. Wizard paths then ran `gatewayConfigStage` and `gatewayBootstrapStage` afterward:
1. finalize writes config, links runtime assets, syncs skills, runs doctor;
2. finalize prints `Installation Summary` + `Mosaic is ready.`;
3. gateway config starts/waits for daemon health;
4. gateway bootstrap runs.
The summary needed to be deferred until after the gateway readiness gates.
## Fix
- `finalizeStage` now returns a `showSummary()` callback and supports `deferSummary`.
- Wizard/quick-start paths call finalize with `deferSummary: true`.
- `showSummary()` is called only after gateway config reports ready and bootstrap completes, or immediately when the caller explicitly skips gateway setup.
- If gateway health/config reports not ready, the wizard returns/aborts without printing the success summary.
- Folded in adjacent runtime install hint fix for Pi: `curl -fsSL https://pi.dev/install.sh | sh`.
## Verification
- Added unified-wizard coverage for summary-after-health and no-summary-on-health-failure.
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard finalize-skills`
- `pnpm format:check`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm build`
- `pnpm test`
- Codex code review: approve.
- Codex security review: one low finding on the requested Pi `curl | sh` install hint; no security finding in the wizard completion-ordering change.
-36
View File
@@ -1,36 +0,0 @@
# B4 — Wizard step deduplication
## Problem
Greenfield wizard testing showed completed wizard steps could be executed again after the menu marked them `[done]`. In practice this made the Providers/API-key flow and Skills flow appear twice in one wizard run.
There was a second related API-key duplication path: when the Providers step was completed with no key, `gatewayConfigStage` still prompted for `ANTHROPIC_API_KEY` during Finish because it only skipped the gateway API-key prompt when `providerKey` was non-empty.
## Diagnosis
- `runMenuLoop` labeled completed sections with `[done]`, but still dispatched the selected step again if the user selected that row.
- Quick Start ran Providers and Skills but did not mark those sections complete in `completedSections`.
- `runFinishPath`/`quickStartPath` defaulted `providerType` to `none` for gateway config, which made it impossible for `gatewayConfigStage` to distinguish:
- provider step completed and user intentionally skipped the key, vs.
- provider step was never run.
## Fix
- Added a shared menu section key helper and a completed-step guard in `runMenuLoop`.
- Completed menu steps now log a skip message instead of re-running their stage.
- Quick Start marks Providers and Skills complete after running them.
- Finish/Quick Start now pass `state.providerType` as-is to gateway config instead of defaulting to `none`.
- `gatewayConfigStage` treats `providerType: 'none'` as an explicit completed provider setup with no key and skips the second gateway API-key prompt.
## Verification
- Added unified wizard regression coverage asserting repeated Providers/Skills menu selections only execute each stage once.
- Added gateway config coverage asserting `providerType: 'none'` does not prompt for a gateway API key and writes no API key env var.
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard gateway-config`
- `pnpm format:check`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm build`
- `pnpm test`
- Codex code review: approve.
- Codex security review: no findings.
@@ -1,60 +0,0 @@
# FED-M3-10 — Federation M3 Integration Tests
## Objective
Add single-gateway gateway integration tests for M3 acceptance #6 and #7.
## Branch / base
- Branch: `feat/federation-m3-integration`
- Base: `origin/next` (`838701bd` after M3-06/#683 merge)
- PR base when unblocked: `next`
## Scope
- Real PostgreSQL via `@mosaicstack/db`.
- Mocked TLS context / Fastify request shim for `FederationAuthGuard`.
- Direct controller calls using the real M3 route contract: `POST /api/federation/v1/list/:resource` with body `{ limit?, cursor? }`.
- Gated by `FEDERATED_INTEGRATION=1`.
- No federation harness dependency.
## Fixture notes
Aligned with the B2 seed design vocabulary:
- `tasks` visibility uses personal `projects` + `missions` chain.
- `notes` are `mission_tasks.notes`; the integration suite asserts subject-only note visibility on an authorized mission.
- Seed includes a second user and unauthorized team/project tasks to prove exclusion from the max-row-cap list result.
- Grants/peers are direct DB fixtures; cert auth still runs through `FederationAuthGuard` using real X.509 certs generated by existing test helpers.
## Current implementation
Added `apps/gateway/src/__tests__/integration/federation-m3-list.integration.test.ts` covering:
1. M3 #6 — cert missing Mosaic OIDs returns 401 federation `unauthorized` envelope.
2. M3 #6 — valid cert whose grant row is `revoked` returns 403 federation `forbidden` envelope.
3. M3 #7 — active grant with `max_rows_per_query: 2` caps `list tasks`, returns `_truncated` + `nextCursor`, source-tags rows, and excludes other-user / unauthorized-team tasks.
4. Cross-user notes invariant — subject can list their own `mission_tasks.notes` row while another user's note on the same authorized mission is excluded.
5. Unsupported-resource invariant — `list widgets` fails closed with a federation `scope_violation` envelope.
## Verification
- `pnpm --filter @mosaicstack/types build` — PASS.
- `pnpm --filter @mosaicstack/db build` — PASS.
- `pnpm --filter @mosaicstack/storage build` — PASS.
- `pnpm --filter @mosaicstack/brain build` — PASS.
- `pnpm --filter @mosaicstack/queue build` — PASS.
- `pnpm --filter @mosaicstack/config build` — PASS.
- `pnpm --filter @mosaicstack/auth build` — PASS.
- `pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS skipped when `FEDERATED_INTEGRATION` unset (5 skipped).
- `FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS (5 tests) after local `docker compose up -d postgres` + `pnpm --filter @mosaicstack/db db:push`.
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
- `pnpm format:check` — PASS.
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS; approve, no findings.
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS; risk level none, no findings.
## Push / PR
- #683 landed in `next`; branch rebased onto `origin/next` before push.
- CI is serialized; run queue guard before push.
@@ -1,40 +0,0 @@
# Installer `--next` fast npm lane — 2026-06-25
## Scope
Flip `tools/install.sh --next` from source-build-first to fast npm `@next` first, with source fallback.
## Registry reality check
Gitea npm registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`
Verified before implementation:
- `@mosaicstack/mosaic@next` resolves to `0.0.49-next.1633`.
- `@mosaicstack/gateway@next` resolves to `0.0.7-next.1633`.
- `@mosaicstack/gateway` dist-tags include `latest: 0.0.6` and `next: 0.0.7-next.1633`.
- `apps/gateway/package.json` is non-private and has Gitea npm `publishConfig`.
Conclusion: the installer can fast-install both CLI and gateway npm packages for `--next`. The gateway Docker `gateway:sha-<short>` remains the deployment/harness artifact; the npm gateway package is valid for the installer global package path.
## Behavior
- `--next` with no explicit ref:
1. framework archive from `next`;
2. resolve `@mosaicstack/gateway@next` and `@mosaicstack/mosaic@next`;
3. require both resolved versions to share the same `next.<pipeline>` suffix;
4. install the exact resolved package versions;
5. set `MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1` so wizard does not overwrite the prerelease gateway;
6. if either package is missing/unreachable/mismatched/fails, fall back to existing source build at `next`.
- `--dev` remains pure source build.
- explicit `--ref` / `MOSAIC_REF` still wins over `--next` and uses the source path for that exact ref.
## Install detail
The installer writes the scoped npmrc mapping (`@mosaicstack:registry=...`) and then runs npm install without overriding npm's default registry. Passing `--registry=<gitea>` to `npm install` forces public transitive dependencies (for example `@anthropic-ai/sdk`) to resolve from Gitea and breaks the fast path; the scoped npmrc mapping is the correct split-registry behavior.
## Verification notes
- Added `tools/install-next-lane.test.sh` with a fake npm/source harness for exact-version fast install, registry failure source fallback, explicit-ref precedence, and mismatched suffix warning.
- Wired the installer harness into `pnpm test` via `pnpm run test:installer`.
- Real temp-prefix fast install succeeded with `@mosaicstack/[email protected]` and `@mosaicstack/[email protected]`.
@@ -1,35 +0,0 @@
# Scratchpad — installer `--next` lane
## Objective
Add a prerelease installer lane for the permanent `next` integration branch.
## Scope
- `tools/install.sh`
- README/install documentation
- Follow-up design note for future npm `@next` prerelease publishing
## Plan
1. Add `--next` and `MOSAIC_NEXT=1` as source-build shorthand for `next`.
2. Preserve explicit ref precedence: `MOSAIC_REF` and `--ref` win over `--next`.
3. Update installer source display/help text.
4. Document three lanes:
- stable npm `@latest`
- prerelease `--next`
- contributor `--dev --ref X`
5. Run shell and repo gates locally, then hold before push/PR until runner serialization greenlight.
## Verification
- `bash -n tools/install.sh` — pass.
- `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable tools/install.sh` — pass.
- `bash tools/install.sh --check --framework --next` — source display shows `ref: next, --next prerelease lane`.
- `bash tools/install.sh --check --cli --next --ref feature-x` — source display shows explicit ref wins.
- `MOSAIC_NEXT=1 MOSAIC_REF=feature-env bash tools/install.sh --check --cli` — source display shows explicit env ref wins.
- `pnpm install --frozen-lockfile --prefer-offline --store-dir /home/jarvis/.local/share/pnpm/store` — pass (local override for repo `.npmrc` CI store path).
- `pnpm typecheck` — pass (41 successful tasks).
- `pnpm lint` — pass (23 successful tasks).
- `pnpm format:check` — pass.
- `bash tools/e2e-install-test.sh` — attempted; current baseline fails during gateway health after stable registry install because Valkey is unavailable in the clean container. The `tools/install.sh --yes --no-auto-launch` stage itself completed before the downstream gateway verification failure.
+1 -2
View File
@@ -10,8 +10,7 @@
"clean:generated": "node scripts/clean-generated.mjs", "clean:generated": "node scripts/clean-generated.mjs",
"typecheck": "pnpm preflight && turbo run typecheck", "typecheck": "pnpm preflight && turbo run typecheck",
"test:checkout": "node --test scripts/*.test.mjs", "test:checkout": "node --test scripts/*.test.mjs",
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer", "test": "pnpm test:checkout && turbo run test",
"test:installer": "bash tools/install-next-lane.test.sh",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
"prepare": "node scripts/install-hooks.mjs" "prepare": "node scripts/install-hooks.mjs"
@@ -11,37 +11,9 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js'; import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
import { createConfigService } from '../../src/config/config-service.js'; import { createConfigService } from '../../src/config/config-service.js';
import type { SelectOption } from '../../src/prompter/interface.js';
import type { MenuSection, WizardState } from '../../src/types.js';
const gatewayConfigMock = vi.fn(); const gatewayConfigMock = vi.fn();
const gatewayBootstrapMock = vi.fn(); const gatewayBootstrapMock = vi.fn();
const providerSetupMock = vi.fn();
const skillsSelectMock = vi.fn();
class SequencedMenuPrompter extends HeadlessPrompter {
constructor(
answers: Record<string, string | boolean | string[]>,
private readonly menuChoices: string[],
) {
super(answers);
}
override async select<T>(opts: {
message: string;
options: SelectOption<T>[];
initialValue?: T;
}): Promise<T> {
if (opts.message === 'What would you like to configure?') {
const next = this.menuChoices.shift();
if (!next) throw new Error('No queued menu choice left');
const match = opts.options.find((o) => String(o.value) === next);
if (!match) throw new Error(`Queued menu choice not available: ${next}`);
return match.value;
}
return super.select(opts);
}
}
vi.mock('../../src/stages/gateway-config.js', () => ({ vi.mock('../../src/stages/gateway-config.js', () => ({
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args), gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
@@ -51,14 +23,6 @@ vi.mock('../../src/stages/gateway-bootstrap.js', () => ({
gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args), gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args),
})); }));
vi.mock('../../src/stages/provider-setup.js', () => ({
providerSetupStage: (...args: unknown[]) => providerSetupMock(...args),
}));
vi.mock('../../src/stages/skills-select.js', () => ({
skillsSelectStage: (...args: unknown[]) => skillsSelectMock(...args),
}));
// Import AFTER the mocks so runWizard picks up the mocked stage modules. // Import AFTER the mocks so runWizard picks up the mocked stage modules.
import { runWizard } from '../../src/wizard.js'; import { runWizard } from '../../src/wizard.js';
@@ -80,16 +44,6 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
} }
gatewayConfigMock.mockReset(); gatewayConfigMock.mockReset();
gatewayBootstrapMock.mockReset(); gatewayBootstrapMock.mockReset();
providerSetupMock.mockReset();
skillsSelectMock.mockReset();
providerSetupMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
state.providerType = 'none';
state.completedSections?.add('providers' satisfies MenuSection);
});
skillsSelectMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
state.selectedSkills = [];
state.completedSections?.add('skills' satisfies MenuSection);
});
// Pretend we're on an interactive TTY so the wizard's headless-abort // Pretend we're on an interactive TTY so the wizard's headless-abort
// branch does not call `process.exit(1)` during these tests. // branch does not call `process.exit(1)` during these tests.
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
@@ -144,12 +98,8 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 }); expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
}); });
it('prints the success summary only after gateway health succeeds', async () => { it('does not invoke bootstrap when config stage reports not ready', async () => {
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => { gatewayConfigMock.mockResolvedValue({ ready: false });
p.log('Gateway is healthy.');
return { ready: true, host: 'localhost', port: 14242 };
});
gatewayBootstrapMock.mockResolvedValue({ completed: true });
const prompter = new HeadlessPrompter({ const prompter = new HeadlessPrompter({
'Installation mode': 'quick', 'Installation mode': 'quick',
@@ -168,43 +118,6 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
skipGatewayNpmInstall: true, skipGatewayNpmInstall: true,
}); });
const logs = prompter.getLogs();
const healthIndex = logs.findIndex((line) => line.includes('Gateway is healthy.'));
const summaryIndex = logs.findIndex((line) => line.includes('Installation Summary'));
const readyIndex = logs.findIndex((line) => line.includes('Mosaic is ready.'));
expect(healthIndex).toBeGreaterThanOrEqual(0);
expect(summaryIndex).toBeGreaterThan(healthIndex);
expect(readyIndex).toBeGreaterThan(summaryIndex);
});
it('does not claim success when gateway health reports not ready', async () => {
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
p.warn('Gateway did not become healthy within 30 seconds.');
return { ready: false };
});
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
'Your pronouns': 'They/Them',
'Your timezone': 'UTC',
});
await runWizard({
mosaicHome: tmpDir,
sourceDir: tmpDir,
prompter,
configService: createConfigService(tmpDir, tmpDir),
skipGatewayNpmInstall: true,
});
const logs = prompter.getLogs();
expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true);
expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false);
expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false);
expect(gatewayConfigMock).toHaveBeenCalledTimes(1); expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
expect(gatewayBootstrapMock).not.toHaveBeenCalled(); expect(gatewayBootstrapMock).not.toHaveBeenCalled();
}); });
@@ -230,34 +143,4 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
expect(gatewayConfigMock).not.toHaveBeenCalled(); expect(gatewayConfigMock).not.toHaveBeenCalled();
expect(gatewayBootstrapMock).not.toHaveBeenCalled(); expect(gatewayBootstrapMock).not.toHaveBeenCalled();
}); });
it('does not re-run completed provider or skills menu steps', async () => {
const prompter = new SequencedMenuPrompter(
{
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
'Your pronouns': 'They/Them',
'Your timezone': 'UTC',
},
['providers', 'providers', 'skills', 'skills', 'finish'],
);
await runWizard({
mosaicHome: tmpDir,
sourceDir: tmpDir,
prompter,
configService: createConfigService(tmpDir, tmpDir),
skipGateway: true,
});
expect(providerSetupMock).toHaveBeenCalledTimes(1);
expect(skillsSelectMock).toHaveBeenCalledTimes(1);
expect(prompter.getLogs()).toEqual(
expect.arrayContaining([
expect.stringContaining('Providers [done] is already complete; skipping.'),
expect.stringContaining('Skills [done] is already complete; skipping.'),
]),
);
});
}); });
+5 -18
View File
@@ -43,16 +43,6 @@ The installer:
- Runs a health audit - Runs a health audit
- Detects existing installs and preserves local files (SOUL.md, USER.md, etc.) - Detects existing installs and preserves local files (SOUL.md, USER.md, etc.)
### Install lanes
| Lane | Command | Use when | Source |
| ------------------------ | ------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | You want the released framework and CLI | npm `@mosaicstack/mosaic@latest` + `main` |
| Prerelease integration | `bash tools/install.sh --next` | You want the permanent `next` integration lane | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are validating a branch before release | Build-from-source at the requested git ref |
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` wins and uses the source path.
## First Run ## First Run
After install, open a new terminal (or `source ~/.bashrc`) and run: After install, open a new terminal (or `source ~/.bashrc`) and run:
@@ -118,8 +108,8 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
├── TOOLS.md ← Machine-level tool reference (generated by mosaic init) ├── TOOLS.md ← Machine-level tool reference (generated by mosaic init)
├── STANDARDS.md ← Machine-wide standards ├── STANDARDS.md ← Machine-wide standards
├── guides/ ← Operational guides (E2E delivery, PRD, docs, etc.) ├── guides/ ← Operational guides (E2E delivery, PRD, docs, etc.)
├── bin/ ← CLI tools (mosaic launcher, mosaic-init, mosaic-doctor, etc.)
├── tools/ ← Tool suites: git, orchestrator, prdy, quality, etc. ├── tools/ ← Tool suites: git, orchestrator, prdy, quality, etc.
│ └── _scripts/ ← Framework helper scripts (sync skills, doctor, runtime links)
├── runtime/ ← Runtime adapters + runtime-specific references ├── runtime/ ← Runtime adapters + runtime-specific references
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks │ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
│ ├── codex/ ← instructions.md, RUNTIME.md │ ├── codex/ ← instructions.md, RUNTIME.md
@@ -184,9 +174,7 @@ The installer preserves local `SOUL.md`, `USER.md`, `TOOLS.md`, and `memory/` by
bash tools/install.sh --check # Version check only bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI) bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework) bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --next # Prerelease lane: npm @next, source fallback bash tools/install.sh --ref v1.0 # Install from a specific git ref
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
``` ```
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage. The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
@@ -197,7 +185,6 @@ The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/ski
```bash ```bash
mosaic sync # Full canonical catalog sync mosaic sync # Full canonical catalog sync
~/.config/mosaic/tools/_scripts/mosaic-sync-skills --link-only # Re-link only
mosaic skill list # Show registered, missing, dangling, and foreign entries mosaic skill list # Show registered, missing, dangling, and foreign entries
mosaic skill register <name> # Register or repair one canonical Claude link mosaic skill register <name> # Register or repair one canonical Claude link
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
@@ -211,7 +198,7 @@ M1 lifecycle management targets Claude Code. Pi can discover the canonical Mosai
```bash ```bash
mosaic doctor # Standard audit mosaic doctor # Standard audit
~/.config/mosaic/tools/_scripts/mosaic-doctor --fail-on-warn # Strict mode ~/.config/mosaic/bin/mosaic-doctor --fail-on-warn # Strict mode
``` ```
## MCP Registration ## MCP Registration
@@ -222,8 +209,8 @@ sequential-thinking MCP is required for Mosaic Stack. The installer registers it
To verify or re-register manually: To verify or re-register manually:
```bash ```bash
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking ~/.config/mosaic/bin/mosaic-ensure-sequential-thinking
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking --check ~/.config/mosaic/bin/mosaic-ensure-sequential-thinking --check
``` ```
### Claude Code MCP Registration ### Claude Code MCP Registration
@@ -9,10 +9,51 @@ WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}
REPO_DIR="$WORK_DIR/repo" REPO_DIR="$WORK_DIR/repo"
STUB_DIR="$WORK_DIR/stubs" STUB_DIR="$WORK_DIR/stubs"
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl" AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
STATUS_OBSERVED="$WORK_DIR/status-observed"
CLOCK_LOG="$WORK_DIR/clock.log"
WATCHDOG_PYTHON="/usr/bin/python3"
WATCHDOG_SCRIPT="$WORK_DIR/real-clock-watchdog.py"
WATCHDOG_TIMEOUT_SEC=5
WATCHDOG_EXIT=90
FEATURE_BRANCH="fix/rm-03-fixture" FEATURE_BRANCH="fix/rm-03-fixture"
if [[ ! -x "$WATCHDOG_PYTHON" ]]; then
echo "FAIL setup: required real-clock watchdog runtime is unavailable at $WATCHDOG_PYTHON" >&2
exit 1
fi
rm -rf "$WORK_DIR" rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$STUB_DIR" mkdir -p "$REPO_DIR" "$STUB_DIR"
cat > "$WATCHDOG_SCRIPT" <<'PY'
import os
import signal
import subprocess
import sys
if len(sys.argv) < 3:
raise SystemExit(2)
timeout_seconds = float(sys.argv[1])
process = subprocess.Popen(sys.argv[2:], start_new_session=True)
try:
return_code = process.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait()
print(
f"FAIL HANG watchdog: subject exceeded {timeout_seconds:g}s "
"before completing its intended path",
file=sys.stderr,
)
raise SystemExit(90)
if return_code < 0:
raise SystemExit(128 - return_code)
raise SystemExit(return_code)
PY
git -C "$REPO_DIR" init -q git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH" git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
@@ -33,6 +74,9 @@ printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
case "$url" in case "$url" in
*/branches/*) */branches/*)
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "hang-before-provider" ]]; then
while :; do :; done
fi
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
exit 7 exit 7
fi fi
@@ -44,6 +88,7 @@ case "$url" in
fi fi
;; ;;
*/status) */status)
: > "${MOSAIC_STUB_STATUS_OBSERVED:?}"
case "${MOSAIC_STUB_STATUS_MODE:?}" in case "${MOSAIC_STUB_STATUS_MODE:?}" in
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;; success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;; pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
@@ -63,7 +108,31 @@ case "$url" in
*) echo "unexpected curl URL: $url" >&2; exit 2 ;; *) echo "unexpected curl URL: $url" >&2; exit 2 ;;
esac esac
SH SH
chmod +x "$STUB_DIR/curl"
cat > "$STUB_DIR/date" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$#" -ne 1 || "$1" != "+%s" ]]; then
echo "unexpected date invocation: $*" >&2
exit 2
fi
if [[ -e "${MOSAIC_STUB_STATUS_OBSERVED:?}" ]]; then
printf 'date-phase=after-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
printf '1002\n'
else
printf 'date-phase=before-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
printf '1000\n'
fi
SH
cat > "$STUB_DIR/sleep" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf 'sleep-after-status=%s\n' "$*" >> "${MOSAIC_STUB_CLOCK_LOG:?}"
SH
chmod +x "$STUB_DIR/curl" "$STUB_DIR/date" "$STUB_DIR/sleep"
run_guard() { run_guard() {
local status_mode="$1" local status_mode="$1"
@@ -83,13 +152,46 @@ run_guard() {
export GITEA_URL=https://git.example.test export GITEA_URL=https://git.example.test
export MOSAIC_STUB_STATUS_MODE="$status_mode" export MOSAIC_STUB_STATUS_MODE="$status_mode"
fi fi
rm -f "$STATUS_OBSERVED" "$CLOCK_LOG"
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log" export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
export MOSAIC_STUB_STATUS_OBSERVED="$STATUS_OBSERVED"
export MOSAIC_STUB_CLOCK_LOG="$CLOCK_LOG"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log" export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 0 -i 0 "$@" # Provider observation is the synchronization event. The one-second
# timeout is subject semantics under virtual time, never a wall wait.
# The absolute Python runtime uses an internal monotonic wait and kills
# the subject's isolated process group. Neither operation can resolve
# to the virtual date/sleep stubs at the front of PATH.
local subject_rc
if "$WATCHDOG_PYTHON" "$WATCHDOG_SCRIPT" "$WATCHDOG_TIMEOUT_SEC" \
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 1 -i 1 "$@"; then
subject_rc=0
else
subject_rc=$?
fi
return "$subject_rc"
) )
} }
failures=0 failures=0
assert_provider_observed() {
local name="$1" require_expiration="${2:-0}"
if [[ ! -e "$STATUS_OBSERVED" ]]; then
echo "FAIL $name: status provider was not observed" >&2
failures=$((failures + 1))
fi
if [[ ! -s "$CLOCK_LOG" ]] || ! grep -q '^date-phase=before-status$' "$CLOCK_LOG"; then
echo "FAIL $name: virtual clock interception did not run before provider observation" >&2
failures=$((failures + 1))
fi
if [[ "$require_expiration" -eq 1 ]]; then
if ! grep -q '^sleep-after-status=' "$CLOCK_LOG" || ! grep -q '^date-phase=after-status$' "$CLOCK_LOG"; then
echo "FAIL $name: pending path did not expire after provider observation" >&2
failures=$((failures + 1))
fi
fi
}
run_assertion() { run_assertion() {
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4" local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
local output rc local output rc
@@ -124,6 +226,13 @@ run_assertion() {
printf '%s\n' "$output" >&2 printf '%s\n' "$output" >&2
failures=$((failures + 1)) failures=$((failures + 1))
fi fi
if [[ "$status_mode" != "credential-unresolvable" ]]; then
if [[ "$status_mode" == "pending" ]]; then
assert_provider_observed "$name" 1
else
assert_provider_observed "$name"
fi
fi
} }
set -e set -e
@@ -140,6 +249,27 @@ run_assertion large-payload not126 large-success 'state=terminal-success'
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT' run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT' run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
# Positive liveness control: a subject mutant hangs before the branch lookup
# can reach the status provider. Only the independent real-clock watchdog may
# terminate it, and its failure must be distinct from subject timeout rc=124.
set +e
watchdog_output=$(MOSAIC_STUB_BRANCH_MODE=hang-before-provider run_guard success "$AUDIT_LOG" 2>&1)
watchdog_rc=$?
set -e
if [[ "$watchdog_rc" -ne "$WATCHDOG_EXIT" ]]; then
echo "FAIL watchdog-control: expected hang-specific rc=$WATCHDOG_EXIT, got rc=$watchdog_rc" >&2
failures=$((failures + 1))
fi
if [[ "$watchdog_output" != *"FAIL HANG watchdog:"* ]]; then
echo "FAIL watchdog-control: expected distinct hang-specific diagnostic" >&2
printf '%s\n' "$watchdog_output" >&2
failures=$((failures + 1))
fi
if [[ -e "$STATUS_OBSERVED" ]]; then
echo "FAIL watchdog-control: hanging mutant unexpectedly reached the status provider" >&2
failures=$((failures + 1))
fi
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2 echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
failures=$((failures + 1)) failures=$((failures + 1))
@@ -160,6 +290,7 @@ if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2 echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
failures=$((failures + 1)) failures=$((failures + 1))
fi fi
assert_provider_observed merge-provider-unreachable
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG") merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2 echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
@@ -223,6 +354,7 @@ if [[ "$audit_failure_output" != *"audit"* ]]; then
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2 echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
failures=$((failures + 1)) failures=$((failures + 1))
fi fi
assert_provider_observed audit-unavailable
if [[ "$failures" -ne 0 ]]; then if [[ "$failures" -ne 0 ]]; then
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2 echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
@@ -39,11 +39,12 @@ ORIG_PATH="$PATH"
# loop — which would make the control a false negative. A root dotfile is # loop — which would make the control a false negative. A root dotfile is
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit. # operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
STRIPPED="$FW/.install-rollback-control.tmp.sh" STRIPPED="$FW/.install-rollback-control.tmp.sh"
SIGNALED="$FW/.install-signal-control.tmp.sh"
NOEXIT="$FW/.install-noexit-control.tmp.sh" NOEXIT="$FW/.install-noexit-control.tmp.sh"
D1CTRL="$FW/.install-d1guard-control.tmp.sh" D1CTRL="$FW/.install-d1guard-control.tmp.sh"
D2CTRL="$FW/.install-d2guard-control.tmp.sh" D2CTRL="$FW/.install-d2guard-control.tmp.sh"
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL" rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
pass=0; fail=0 pass=0; fail=0
chk() { if eval "$2"; then echo "$1"; pass=$((pass + 1)); else echo "$1"; fail=$((fail + 1)); fi; } chk() { if eval "$2"; then echo "$1"; pass=$((pass + 1)); else echo "$1"; fail=$((fail + 1)); fi; }
@@ -180,41 +181,86 @@ chk "[control] without -E the mid-sync corruption survives (no rollback)" \
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ────── # ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
# A bash signal trap that merely returns lets the script continue past the # A bash signal trap that merely returns lets the script continue past the
# interrupt — restoring the snapshot, then resuming the sync and reporting # interrupt — restoring the snapshot, then resuming the sync and reporting
# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never # success. The earlier test used a child cp shim to signal its parent, making
# fires and ONLY the signal path governs), and assert the shipped installer # child completion race Bash's interrupted wait. Concurrency is not part of the
# restores AND exits without reporting success. The control strips `exit 1` from # guarded property: sync_framework_keep() runs in the installer's own Bash
# the trap and shows the buggy resume-to-success. # process, and `kill` is a builtin. Generate two installer fixtures that signal
make_term_shim() { # themselves at the same known mid-sync point. Their TERM handlers emit the same
local dir="$1" # observable before diverging, so missing signal delivery fails BOTH arms rather
cat > "$dir/cp" <<SHIM # than manufacturing a pass. The only semantic difference between fixtures is
#!/usr/bin/env bash # the explicit `exit 1` whose load-bearing behavior this control proves.
dest="\${@: -1}" TERM_MARKER='[test-control] TERM handler entered'
case "\$dest" in HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
*/$POISON_REL) HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
exec env PATH="$ORIG_PATH" cp "\$@" ;; make_signal_installer() {
esac local output="$1" handler="$2"
exec env PATH="$ORIG_PATH" cp "\$@" local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
SHIM local target_cp=' cp "$abs" "$dst/$rel"'
chmod +x "$dir/cp" local inject_open=" if [[ \"\$rel\" == \"$POISON_REL\" ]]; then"
local inject_kill=' kill -TERM "$$" # TEST-TERM-INJECTION'
local inject_close=' fi'
if ! awk \
-v target_trap="$target_trap" -v target_cp="$target_cp" \
-v handler="$handler" -v inject_open="$inject_open" \
-v inject_kill="$inject_kill" -v inject_close="$inject_close" '
$0 == target_cp {
print inject_open
print inject_kill
print inject_close
injection_sites++
}
{ print }
$0 == target_trap {
print handler
handler_sites++
}
END {
if (handler_sites != 1 || injection_sites != 1) exit 42
}
' "$INSTALL" > "$output"; then
rm -f "$output"
fail "Could not construct the self-TERM control installer at the exact trap/copy sites"
exit 1
fi
chmod +x "$output"
} }
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>". make_signal_installer "$SIGNALED" "$HANDLER_WITH_EXIT"
make_signal_installer "$NOEXIT" "$HANDLER_WITHOUT_EXIT"
signal_fixture_ready() {
local fixture="$1" expected_handler="$2"
[[ "$(grep -cF '# TEST-TERM-INJECTION' "$fixture")" -eq 1 ]] \
&& [[ "$(grep -cF '# TEST-TERM-HANDLER' "$fixture")" -eq 1 ]] \
&& grep -Fqx "$expected_handler" "$fixture"
}
signaled_fixture_ready() { signal_fixture_ready "$SIGNALED" "$HANDLER_WITH_EXIT"; }
noexit_fixture_ready() { signal_fixture_ready "$NOEXIT" "$HANDLER_WITHOUT_EXIT"; }
chk "[signal] shipped fixture has exactly one self-TERM injection and marked handler" \
"signaled_fixture_ready"
chk "[control] no-exit fixture has exactly one self-TERM injection and marked handler" \
"noexit_fixture_ready"
chk "[control] removing the explicit TERM exit changes the fixture" \
"! cmp -s '$SIGNALED' '$NOEXIT'"
# Run one keep-mode upgrade whose own shell delivers SIGTERM synchronously at
# the selected copy. Echoes "<exit>\t<out>\t<home>".
run_signal_upgrade() { run_signal_upgrade() {
local installer="$1" H OUT SHIM rc local installer="$1" H OUT rc
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d) H=$(mktemp -d); OUT=$(mktemp)
seed_home "$H" seed_home "$H"
make_term_shim "$SHIM"
set +e set +e
PATH="$SHIM:$ORIG_PATH" \ PATH="$ORIG_PATH" \
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1 MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
rc=$? rc=$?
set -e 2>/dev/null || true set -e 2>/dev/null || true
rm -rf "$SHIM"
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H" printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
} }
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL") IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$SIGNALED")
chk "[signal] TERM handler observable fires exactly once" \
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTC')\" -eq 1 ]"
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \ chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
"[ '$rcC' -ne 0 ]" "[ '$rcC' -ne 0 ]"
chk "[signal] restore_snapshot fires on the interrupt" \ chk "[signal] restore_snapshot fires on the interrupt" \
@@ -222,13 +268,13 @@ chk "[signal] restore_snapshot fires on the interrupt" \
chk "[signal] does NOT resume to report sync success after the interrupt" \ chk "[signal] does NOT resume to report sync success after the interrupt" \
"! grep -q 'file phase complete' '$OUTC'" "! grep -q 'file phase complete' '$OUTC'"
# Control: strip `exit 1` from the signal trap → the handler returns, the script IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves. chk "[control] TERM handler observable fires exactly once" \
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \ "[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]"
"$INSTALL" > "$NOEXIT" chk "[control] without 'exit 1' the handler restores before returning" \
chk "[control] the exit-strip actually changed the installer" \ "grep -q 'restoring previous state from snapshot' '$OUTD'"
"! cmp -s '$INSTALL' '$NOEXIT'" chk "[control] without 'exit 1' the installer exits zero after resuming" \
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT") "[ '$rcD' -eq 0 ]"
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \ chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
"grep -q 'file phase complete' '$OUTD'" "grep -q 'file phase complete' '$OUTD'"
@@ -309,10 +355,10 @@ chk "[control] without the D2 recovery line the operator gets no snapshot pointe
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned). # Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done
# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL are also removed by the EXIT trap). # Cleanup (generated installer controls are also removed by the EXIT trap).
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \ rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL" "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
echo echo
echo "RESULT: $pass passed, $fail failed" echo "RESULT: $pass passed, $fail failed"
+5
View File
@@ -23,6 +23,7 @@ import { registerSkillCommand } from './commands/skill.js';
import { registerLaunchCommands } from './commands/launch.js'; import { registerLaunchCommands } from './commands/launch.js';
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js'; import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
import { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js'; import { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js';
import { registerBrainProvisionCommand } from './commands/brain-provision-command.js';
import { registerAuthCommand } from './commands/auth.js'; import { registerAuthCommand } from './commands/auth.js';
import { registerFederationCommand } from './commands/federation.js'; import { registerFederationCommand } from './commands/federation.js';
import { registerGatewayCommand } from './commands/gateway.js'; import { registerGatewayCommand } from './commands/gateway.js';
@@ -85,6 +86,10 @@ registerLeaseCapabilityProbe(program);
registerInstallOrderingGuardCommand(program); registerInstallOrderingGuardCommand(program);
// ─── durable brain P7 provisioner (hidden; #1051) ───────────────────────
registerBrainProvisionCommand(program);
// ─── login ────────────────────────────────────────────────────────────── // ─── login ──────────────────────────────────────────────────────────────
program program
@@ -0,0 +1,272 @@
import { afterEach, describe, expect, it } from 'vitest';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
interface CommandRequest {
readonly program: 'git' | 'mosaic';
readonly args: readonly string[];
readonly env: Readonly<Record<string, string>>;
}
interface CommandResult {
readonly status: number;
readonly stdout: string;
readonly stderr: string;
}
interface InstalledDoctorResult {
readonly status: 'ok' | 'warn' | 'error';
readonly findings: readonly {
readonly code: string;
readonly reasonCode: string | null;
}[];
readonly lines: readonly string[];
}
interface BrainDoctorModule {
runInstalledBrainDoctorCheck(
options: {
readonly mosaicHome: string;
readonly home: string;
readonly identity?: string;
readonly fix: boolean;
},
run: (request: CommandRequest) => CommandResult,
): InstalledDoctorResult;
}
const MODULE_PATH = './brain-doctor-check.js';
const roots: string[] = [];
async function loadDoctor(requirement: string): Promise<BrainDoctorModule> {
try {
return (await import(MODULE_PATH)) as BrainDoctorModule;
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`${requirement}: installed brain doctor check is absent (${detail})`);
}
}
function tempRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-doctor-'));
roots.push(root);
return root;
}
function installConfig(root: string): { readonly home: string; readonly mosaicHome: string } {
const home = join(root, 'home');
const mosaicHome = join(home, '.config', 'mosaic');
mkdirSync(join(mosaicHome, 'cred'), { recursive: true });
mkdirSync(join(mosaicHome, 'brain'), { recursive: true });
writeFileSync(
join(mosaicHome, 'cred', 'estates.json'),
JSON.stringify({
version: 1,
estates: [
{
name: 'homelab',
readOnlyControlIdentity: 'read-control',
hosts: [
{
host: 'git.example.invalid',
provider: 'gitea',
apiBaseUrl: 'https://git.example.invalid',
tokenPrefix: 'gitea-example',
},
],
},
],
}),
{ mode: 0o600 },
);
writeFileSync(
join(mosaicHome, 'brain', 'owners.json'),
JSON.stringify({
version: 1,
estates: [
{
estate: 'homelab',
laneArchiveOwners: [{ kind: 'provider-user', login: 'durable-owner' }],
standingProcess: { kind: 'glpi-queue', queue: 'mosaic-brain-remediation' },
controls: { publicIdentity: 'public-control', privateIdentity: 'private-control' },
},
],
}),
{ mode: 0o600 },
);
writeFileSync(
join(mosaicHome, '.install-manifest.json'),
JSON.stringify({
version: 2,
status: 'committed',
sourceRepo: 'https://git.example.invalid/example/stack.git',
}),
{ mode: 0o600 },
);
return { home, mosaicHome };
}
function validateResult(outcome: 'ok' | 'refused' | 'indeterminate', reasonCode: string): string {
const exitCode = outcome === 'ok' ? 0 : outcome === 'refused' ? 10 : 30;
return JSON.stringify({
schemaVersion: 1,
operation: 'validate',
outcome,
exitCode,
retryable: false,
subject: {
identity: 'seat-a',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'durable-owner/mosaic-brain',
},
mutation: 'none',
reason: { code: reasonCode, message: 'non-secret' },
evidence: {
providerIdentity:
outcome === 'ok'
? {
login: 'seat-a',
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
}
: null,
repositoryPermission:
outcome === 'ok'
? {
requested: 'write',
effective: 'write',
endpoint: 'GET /api/v1/repos/durable-owner/mosaic-brain',
contentType: 'application/json',
}
: null,
writeDifferential:
outcome === 'ok'
? {
state: 'can-write',
credentialBinding: 'same-resolution',
transportPrincipal: 'seat-a',
authenticatedReceivePack: 'advertised',
readOnlyControl: {
identity: 'read-control',
providerPermission: 'read',
receivePack: 'refused',
},
unauthenticatedReceivePack: 'refused',
artifactCreated: false,
proves: 'non-secret evidence',
doesNotProve: 'branch update acceptance',
}
: null,
},
audit: { journalId: 'opaque', state: 'sealed' },
});
}
afterEach((): void => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
describe('installed mosaic doctor brain checks', (): void => {
it('derives the target from the committed install manifest and surfaces a missing clone plus refusal', async (): Promise<void> => {
const doctor = await loadDoctor('MB-REQ-08 installed doctor missing clone');
const config = installConfig(tempRoot());
const requests: CommandRequest[] = [];
const result = doctor.runInstalledBrainDoctorCheck(
{ ...config, identity: 'seat-a', fix: false },
(request): CommandResult => {
requests.push(request);
return {
status: 10,
stdout: validateResult('refused', 'no-token-for-identity'),
stderr: 'refused reason=no-token-for-identity',
};
},
);
expect(result.status).toBe('warn');
expect(result.findings.map((finding) => finding.code)).toEqual(
expect.arrayContaining(['brain-clone-missing', 'brain-write-access-refused']),
);
expect(result.lines.join('\n')).toMatch(/brain-clone-missing/);
expect(result.lines.join('\n')).toMatch(/no-token-for-identity/);
expect(requests[0]?.args).toContain('durable-owner/mosaic-brain');
});
it('fails closed without an explicit identity and performs no command', async (): Promise<void> => {
const doctor = await loadDoctor('MB-REQ-08 explicit identity');
const config = installConfig(tempRoot());
let calls = 0;
const result = doctor.runInstalledBrainDoctorCheck(
{ ...config, fix: false },
(): CommandResult => {
calls += 1;
return { status: 0, stdout: '', stderr: '' };
},
);
expect(result).toMatchObject({
status: 'error',
findings: [{ code: 'brain-identity-required', reasonCode: 'identity-required' }],
});
expect(calls).toBe(0);
});
it('treats identity-not-measured as an error, not no-write refusal and not a repairable grant case', async (): Promise<void> => {
const doctor = await loadDoctor('MB-REQ-08 identity measurement axis');
const config = installConfig(tempRoot());
const requests: CommandRequest[] = [];
const result = doctor.runInstalledBrainDoctorCheck(
{ ...config, identity: 'seat-a', fix: true },
(request): CommandResult => {
requests.push(request);
return {
status: 30,
stdout: validateResult('indeterminate', 'identity-not-measured'),
stderr: 'identity not measured',
};
},
);
expect(result.status).toBe('error');
expect(result.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: 'brain-write-access-indeterminate',
reasonCode: 'identity-not-measured',
}),
]),
);
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
});
it('is wired into the top-level mosaic doctor path before the shell audit runs', (): void => {
const launch = readFileSync(join(process.cwd(), 'src', 'commands', 'launch.ts'), 'utf8');
expect(launch).toContain('runInstalledBrainDoctorCheck');
expect(launch).toContain('defaultInstalledBrainDoctorOptions');
expect(launch).toContain('systemCommandRunner');
expect(launch).toMatch(/brainCheckFailed[\s\S]*runDoctorScriptAndExit/);
});
it('reports a missing or unsafe registry/manifest as configuration error rather than defaulting estate', async (): Promise<void> => {
const doctor = await loadDoctor('MB-REQ-02 missing mapping fail-closed');
const root = tempRoot();
const home = join(root, 'home');
const mosaicHome = join(home, '.config', 'mosaic');
mkdirSync(mosaicHome, { recursive: true });
const result = doctor.runInstalledBrainDoctorCheck(
{ home, mosaicHome, identity: 'seat-a', fix: false },
(): CommandResult => ({ status: 0, stdout: '', stderr: '' }),
);
expect(result.status).toBe('error');
expect(result.findings[0]?.code).toMatch(/brain-(estate-registry|install-manifest)-/);
expect(result.lines.join('\n')).not.toMatch(/homelab|usc/);
});
});
@@ -0,0 +1,152 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
import { z } from 'zod';
import { readBrainConfigSecure } from './brain-secure-config.js';
import { resolveBrainOwnerPolicy } from './brain-owner-resolver.js';
import { deriveBrainTarget } from './brain-store.js';
import {
collectBrainDoctorReport,
repairBrainDoctor,
type CommandRunner,
type DoctorRuntimeReport,
} from './brain-store-runtime.js';
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
const manifestSchema = z
.object({
version: z.literal(2),
status: z.literal('committed'),
sourceRepo: z.string().min(1),
})
.passthrough();
export interface InstalledDoctorFinding {
readonly code: string;
readonly reasonCode: string | null;
}
export interface InstalledDoctorResult {
readonly status: 'ok' | 'warn' | 'error';
readonly findings: readonly InstalledDoctorFinding[];
readonly lines: readonly string[];
}
function configurationError(code: string, reasonCode = code): InstalledDoctorResult {
return {
status: 'error',
findings: [{ code, reasonCode }],
lines: [`[mosaic-doctor] [ERROR] ${code}`],
};
}
function renderReport(report: DoctorRuntimeReport): InstalledDoctorResult {
const findings = report.findings.map(
(finding): InstalledDoctorFinding => ({
code: finding.code,
reasonCode: finding.reasonCode,
}),
);
const hard = findings.some(
(finding): boolean =>
finding.code.endsWith('-error') ||
finding.code.endsWith('-indeterminate') ||
finding.code === 'brain-not-git-repository' ||
finding.code === 'brain-root-permissions-unsafe',
);
const status: InstalledDoctorResult['status'] =
findings.length === 0 ? 'ok' : hard ? 'error' : 'warn';
const severity = status === 'error' ? 'ERROR' : status === 'warn' ? 'WARN' : 'OK';
const lines =
findings.length === 0
? ['[mosaic-doctor] [OK] mosaic-brain ready']
: findings.map(
(finding): string =>
`[mosaic-doctor] [${severity}] ${finding.code}${
finding.reasonCode === null ? '' : ` reason=${finding.reasonCode}`
}`,
);
return { status, findings, lines };
}
export function runInstalledBrainDoctorCheck(
options: {
readonly mosaicHome: string;
readonly home: string;
readonly identity?: string;
readonly fix: boolean;
},
run: CommandRunner,
): InstalledDoctorResult {
if (options.identity === undefined || !IDENTITY.test(options.identity)) {
return configurationError('brain-identity-required', 'identity-required');
}
const registryPath = join(options.mosaicHome, 'cred', 'estates.json');
const manifestPath = join(options.mosaicHome, '.install-manifest.json');
const ownerPolicyPath = join(options.mosaicHome, 'brain', 'owners.json');
let registrySource: string;
try {
registrySource = readBrainConfigSecure(registryPath, options.mosaicHome);
} catch {
return configurationError('brain-estate-registry-unavailable');
}
let manifestSource: string;
try {
manifestSource = readBrainConfigSecure(manifestPath, options.mosaicHome);
} catch {
return configurationError('brain-install-manifest-unavailable');
}
let manifestRaw: unknown;
try {
manifestRaw = JSON.parse(manifestSource);
} catch {
return configurationError('brain-install-manifest-invalid');
}
const manifest = manifestSchema.safeParse(manifestRaw);
if (!manifest.success) return configurationError('brain-install-manifest-invalid');
let ownerPolicySource: string;
try {
ownerPolicySource = readBrainConfigSecure(ownerPolicyPath, options.mosaicHome);
} catch {
return configurationError('brain-owner-policy-unavailable');
}
let preliminaryTarget: ReturnType<typeof deriveBrainTarget>;
try {
preliminaryTarget = deriveBrainTarget(registrySource, manifest.data.sourceRepo, 'policy-probe');
} catch {
return configurationError('brain-estate-registry-invalid');
}
const ownerPolicy = resolveBrainOwnerPolicy(ownerPolicySource, preliminaryTarget.estate);
if (ownerPolicy === undefined) return configurationError('brain-owner-policy-invalid');
const input = {
registrySource,
targetGitUrl: manifest.data.sourceRepo,
brainNamespace: ownerPolicy.brainNamespace,
identity: options.identity,
root: join(options.home, '.mosaic'),
};
try {
return renderReport(
options.fix ? repairBrainDoctor(input, run) : collectBrainDoctorReport(input, run),
);
} catch {
return configurationError('brain-estate-registry-invalid');
}
}
export function defaultInstalledBrainDoctorOptions(fix: boolean): {
readonly mosaicHome: string;
readonly home: string;
readonly identity?: string;
readonly fix: boolean;
} {
const home = homedir();
const identity = process.env['MOSAIC_GIT_IDENTITY'];
return {
mosaicHome: process.env['MOSAIC_HOME'] ?? join(home, '.config', 'mosaic'),
home,
...(identity === undefined ? {} : { identity }),
fix,
};
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
function installerSource(): string {
return readFileSync(join(process.cwd(), '..', '..', 'tools', 'install.sh'), 'utf8');
}
describe('root installer P7 durable-brain integration', (): void => {
it('records the configured source repository in the committed manifest for doctor derivation', (): void => {
const installer = installerSource();
expect(installer).toContain('sourceRepo:');
expect(installer).toMatch(/sourceRepo:\s*process\.argv\[/);
expect(installer).toMatch(/MANIFEST_SOURCE_REPO/);
});
it('invokes the broker-only provision command in P7 with explicit owner and refusal controls', (): void => {
const installer = installerSource();
const provision = installer.indexOf('__brain-provision');
const p7 = installer.indexOf('state_phase_begin P7');
expect(provision).toBeGreaterThan(-1);
expect(p7).toBeGreaterThan(-1);
expect(provision).toBeGreaterThan(p7);
for (const flag of [
'--identity',
'--target-url',
'--owner',
'--refusal-identity',
'--lane',
'--owner-policy',
]) {
expect(installer).toContain(flag);
}
expect(installer).not.toMatch(/__brain-provision[^\n]*(?:token|password|authorization)/i);
});
it('journals ~/.mosaic and the owner policy as P7 mutations and checks the resulting object', (): void => {
const installer = installerSource();
expect(installer).toContain('state_record_mutation P7 "$HOME/.mosaic"');
expect(installer).toContain('state_record_mutation P7 "$MOSAIC_HOME/brain/owners.json"');
expect(installer).toMatch(/P7\)[\s\S]*\.mosaic[\s\S]*(?:remote|get-url)[\s\S]*main/);
});
it('discovers every legacy lane directory rather than silently migrating only one lane', (): void => {
const installer = installerSource();
expect(installer).toMatch(/memory\/lanes/);
expect(installer).toMatch(/for\s+[^\n]*lane/);
expect(installer).toMatch(/__brain-provision[\s\S]*--lane/);
});
});
@@ -0,0 +1,373 @@
import { describe, expect, it } from 'vitest';
/**
* Red-first owner-authority resolver contract for #1051.
*
* Fixtures are operator-agnostic. The HOMELAB owner name belongs in the local
* estate policy, never in framework source. Anonymous lookup is intentional:
* the ruled owner class is PUBLIC and least-privilege seats may lack read:user.
*/
interface MigrationOwnerResolution {
readonly verdict: 'resolved' | 'refused' | 'not-measured';
readonly reasonCode: string;
readonly principal: {
readonly name: string;
readonly kind: 'durable-human';
} | null;
readonly authority: {
readonly system: 'gitea';
readonly endpoint: string;
readonly contentType: 'application/json';
} | null;
}
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
interface OwnerResolverModule {
resolveProviderDurableOwner(
input: {
readonly estateRegistrySource: string;
readonly ownerPolicySource: string;
readonly host: string;
readonly requestedOwner: string;
},
dependencies: {
readonly fetch: FetchLike;
readonly absentControlName: () => string;
},
): Promise<MigrationOwnerResolution>;
}
const MODULE_PATH = './brain-owner-resolver.js';
async function loadResolver(requirement: string): Promise<OwnerResolverModule> {
try {
return (await import(MODULE_PATH)) as OwnerResolverModule;
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`${requirement}: brain owner resolver is absent (${detail})`);
}
}
function estateRegistry(): string {
return JSON.stringify({
version: 1,
estates: [
{
name: 'homelab',
readOnlyControlIdentity: 'read-control',
hosts: [
{
host: 'git.example.invalid',
provider: 'gitea',
apiBaseUrl: 'https://git.example.invalid',
tokenPrefix: 'gitea-example',
},
],
},
],
});
}
function ownerPolicy(): string {
return JSON.stringify({
version: 1,
estates: [
{
estate: 'homelab',
laneArchiveOwners: [{ kind: 'provider-user', login: 'durable-owner' }],
standingProcess: { kind: 'glpi-queue', queue: 'mosaic-brain-remediation' },
controls: {
publicIdentity: 'public-control',
privateIdentity: 'private-control',
},
},
],
});
}
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json; charset=utf-8' },
});
}
function publicUser(login: string, active = false): Response {
return jsonResponse(200, {
id: 42,
login,
visibility: 'public',
active,
});
}
function identityFromUrl(input: string | URL | Request): string {
const value = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
return decodeURIComponent(new URL(value).pathname.split('/').at(-1) ?? '');
}
function controlledFetch(
overrides: Readonly<Record<string, Response>> = {},
calls: Array<{ identity: string; authorization: string | null }> = [],
): FetchLike {
return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const identity = identityFromUrl(input);
const headers = new Headers(init?.headers);
calls.push({ identity, authorization: headers.get('authorization') });
const override = overrides[identity];
if (override !== undefined) return override.clone();
if (identity === 'public-control') return publicUser('public-control');
if (identity === 'private-control' || identity === 'generated-absent-control') {
return jsonResponse(404, { message: 'not found' });
}
if (identity === 'durable-owner') return publicUser('durable-owner', false);
return jsonResponse(404, { message: 'not found' });
};
}
describe('provider-backed durable owner resolver', (): void => {
it('resolves an allowlisted PUBLIC owner by exact login with public/private/absent controls and ignores active=false', async (): Promise<void> => {
const resolver = await loadResolver('MB-REQ-09 provider owner resolution');
const calls: Array<{ identity: string; authorization: string | null }> = [];
const result = await resolver.resolveProviderDurableOwner(
{
estateRegistrySource: estateRegistry(),
ownerPolicySource: ownerPolicy(),
host: 'git.example.invalid',
requestedOwner: 'user:durable-owner',
},
{
fetch: controlledFetch({}, calls),
absentControlName: (): string => 'generated-absent-control',
},
);
expect(result).toEqual({
verdict: 'resolved',
reasonCode: 'owner-verified',
principal: { name: 'user:durable-owner', kind: 'durable-human' },
authority: {
system: 'gitea',
endpoint: 'GET /api/v1/users/durable-owner',
contentType: 'application/json',
},
});
expect(calls.map((call) => call.identity)).toEqual([
'public-control',
'private-control',
'generated-absent-control',
'durable-owner',
]);
expect(calls.every((call) => call.authorization === null)).toBe(true);
});
it('refuses provider redirects and configures a bounded no-redirect request', async (): Promise<void> => {
const resolver = await loadResolver('MB-REQ-09 owner lookup SSRF boundary');
const requests: RequestInit[] = [];
const result = await resolver.resolveProviderDurableOwner(
{
estateRegistrySource: estateRegistry(),
ownerPolicySource: ownerPolicy(),
host: 'git.example.invalid',
requestedOwner: 'user:durable-owner',
},
{
fetch: async (_input, init): Promise<Response> => {
requests.push(init ?? {});
return new Response(JSON.stringify({ message: 'redirect' }), {
status: 302,
headers: {
'content-type': 'application/json',
location: 'http://127.0.0.1/internal',
},
});
},
absentControlName: (): string => 'generated-absent-control',
},
);
expect(result).toMatchObject({ verdict: 'not-measured', reasonCode: 'owner-control-invalid' });
expect(requests).toHaveLength(1);
expect(requests[0]?.redirect).toBe('manual');
expect(requests[0]?.signal).toBeInstanceOf(AbortSignal);
});
it('cancels a chunked provider body as soon as it exceeds the byte ceiling', async (): Promise<void> => {
const resolver = await loadResolver('MB-REQ-09 bounded owner response stream');
let cancelled = false;
const oversized = new ReadableStream<Uint8Array>({
start(controller): void {
controller.enqueue(new Uint8Array(200_000));
controller.enqueue(new Uint8Array(100_000));
},
cancel(): void {
cancelled = true;
},
});
const result = await resolver.resolveProviderDurableOwner(
{
estateRegistrySource: estateRegistry(),
ownerPolicySource: ownerPolicy(),
host: 'git.example.invalid',
requestedOwner: 'user:durable-owner',
},
{
fetch: async (): Promise<Response> =>
new Response(oversized, {
status: 200,
headers: { 'content-type': 'application/json' },
}),
absentControlName: (): string => 'generated-absent-control',
},
);
expect(result).toMatchObject({
verdict: 'not-measured',
reasonCode: 'owner-unexpected-provider-shape',
});
expect(cancelled).toBe(true);
});
it('requires the GLPI standing remediation queue in the local estate policy', async (): Promise<void> => {
const resolver = await loadResolver('MB-REQ-09 standing process policy');
const raw = JSON.parse(ownerPolicy()) as { estates: Array<Record<string, unknown>> };
delete raw.estates[0]?.['standingProcess'];
let fetchCalls = 0;
const result = await resolver.resolveProviderDurableOwner(
{
estateRegistrySource: estateRegistry(),
ownerPolicySource: JSON.stringify(raw),
host: 'git.example.invalid',
requestedOwner: 'user:durable-owner',
},
{
fetch: async (): Promise<Response> => {
fetchCalls += 1;
return publicUser('durable-owner');
},
absentControlName: (): string => 'generated-absent-control',
},
);
expect(result).toMatchObject({ verdict: 'refused', reasonCode: 'owner-policy-invalid' });
expect(fetchCalls).toBe(0);
});
it('rejects a provider-valid but unlisted principal before provider lookup', async (): Promise<void> => {
const resolver = await loadResolver('MB-REQ-09 provider-valid unlisted owner refusal');
const calls: Array<{ identity: string; authorization: string | null }> = [];
const result = await resolver.resolveProviderDurableOwner(
{
estateRegistrySource: estateRegistry(),
ownerPolicySource: ownerPolicy(),
host: 'git.example.invalid',
requestedOwner: 'user:other-public-user',
},
{
fetch: controlledFetch({ 'other-public-user': publicUser('other-public-user') }, calls),
absentControlName: (): string => 'generated-absent-control',
},
);
expect(result).toMatchObject({ verdict: 'refused', reasonCode: 'owner-not-allowlisted' });
expect(calls).toHaveLength(0);
});
it.each([
['user:durableowner', 'owner-name-invalid'],
[' user:durable-owner ', 'owner-name-invalid'],
['user:durable.owner', 'owner-not-allowlisted'],
['user:durable owner', 'owner-name-invalid'],
['user:urable-owner', 'owner-name-invalid'],
['user:be-coder-07@mission-seat', 'owner-name-invalid'],
] as const)(
'rejects non-canonical, unlisted, or transient-seat presentation %s before lookup',
async (name, reasonCode): Promise<void> => {
const resolver = await loadResolver('MB-REQ-09 owner allowlist grammar');
let fetchCalls = 0;
const result = await resolver.resolveProviderDurableOwner(
{
estateRegistrySource: estateRegistry(),
ownerPolicySource: ownerPolicy(),
host: 'git.example.invalid',
requestedOwner: name,
},
{
fetch: async (): Promise<Response> => {
fetchCalls += 1;
return publicUser('durable-owner');
},
absentControlName: (): string => 'generated-absent-control',
},
);
expect(result).toMatchObject({ verdict: 'refused', reasonCode });
expect(fetchCalls).toBe(0);
},
);
it('fails closed as not-resolvable rather than claiming a private-or-absent owner does not exist', async (): Promise<void> => {
const resolver = await loadResolver('MB-REQ-09 private/absent ambiguity');
const result = await resolver.resolveProviderDurableOwner(
{
estateRegistrySource: estateRegistry(),
ownerPolicySource: ownerPolicy(),
host: 'git.example.invalid',
requestedOwner: 'user:durable-owner',
},
{
fetch: controlledFetch({ 'durable-owner': jsonResponse(404, { message: 'hidden' }) }),
absentControlName: (): string => 'generated-absent-control',
},
);
expect(result).toMatchObject({
verdict: 'not-measured',
reasonCode: 'owner-not-resolvable',
principal: null,
});
expect(JSON.stringify(result)).not.toMatch(/owner-not-found|does-not-exist/);
});
it.each([
['public control hidden', { 'public-control': jsonResponse(404, {}) }],
['public control login mismatch', { 'public-control': publicUser('other') }],
['private control unexpectedly public', { 'private-control': publicUser('private-control') }],
[
'generated absent control unexpectedly resolves',
{ 'generated-absent-control': publicUser('generated-absent-control') },
],
] as const)(
'makes the whole result not-measured when %s',
async (_caseName, overrides): Promise<void> => {
const resolver = await loadResolver('MB-REQ-09 owner resolver controls');
const result = await resolver.resolveProviderDurableOwner(
{
estateRegistrySource: estateRegistry(),
ownerPolicySource: ownerPolicy(),
host: 'git.example.invalid',
requestedOwner: 'user:durable-owner',
},
{
fetch: controlledFetch(overrides),
absentControlName: (): string => 'generated-absent-control',
},
);
expect(result).toMatchObject({
verdict: 'not-measured',
reasonCode: 'owner-control-invalid',
});
},
);
});
@@ -0,0 +1,280 @@
import { z } from 'zod';
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
import type { MigrationOwnerResolution } from './brain-store.js';
const MAX_BODY_BYTES = 256 * 1024;
const LOGIN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/;
const REQUESTED_OWNER = /^user:(.+)$/;
const ownerPolicySchema = z
.object({
version: z.literal(1),
estates: z
.array(
z
.object({
estate: z.string().min(1),
laneArchiveOwners: z
.array(
z
.object({
kind: z.literal('provider-user'),
login: z.string().min(1),
})
.strict(),
)
.min(1),
standingProcess: z
.object({
kind: z.literal('glpi-queue'),
queue: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
})
.strict(),
controls: z
.object({
publicIdentity: z.string().min(1),
privateIdentity: z.string().min(1),
})
.strict(),
})
.strict(),
)
.min(1),
})
.strict();
const providerUserSchema = z
.object({
id: z.number().int(),
login: z.string().min(1),
visibility: z.literal('public'),
})
.passthrough();
export type OwnerFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
function unresolved(reasonCode: string): MigrationOwnerResolution {
return {
verdict: 'not-measured',
reasonCode,
principal: null,
authority: null,
};
}
function refused(reasonCode: string): MigrationOwnerResolution {
return {
verdict: 'refused',
reasonCode,
principal: null,
authority: null,
};
}
function exactCanonicalLogin(value: string): boolean {
return value.normalize('NFKC') === value && LOGIN.test(value);
}
async function boundedJson(response: Response): Promise<unknown> {
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.toLowerCase().startsWith('application/json')) {
throw new Error('owner-unexpected-content-type');
}
const declared = response.headers.get('content-length');
let declaredSize: number | null = null;
if (declared !== null) {
if (!/^\d+$/.test(declared)) throw new Error('owner-unexpected-provider-shape');
declaredSize = Number.parseInt(declared, 10);
if (!Number.isSafeInteger(declaredSize) || declaredSize > MAX_BODY_BYTES) {
throw new Error('owner-unexpected-provider-shape');
}
}
if (response.body === null) throw new Error('owner-unexpected-provider-shape');
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const next = await reader.read();
if (next.done) break;
total += next.value.byteLength;
if (total > MAX_BODY_BYTES) {
await reader.cancel('owner response exceeds byte ceiling');
throw new Error('owner-unexpected-provider-shape');
}
chunks.push(next.value);
}
if (declaredSize !== null && declaredSize !== total) {
throw new Error('owner-unexpected-provider-shape');
}
const body = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
try {
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body));
} catch {
throw new Error('owner-unexpected-provider-shape');
}
}
async function readPublicIdentity(
origin: string,
identity: string,
fetchImpl: OwnerFetch,
): Promise<{ readonly status: number; readonly user: unknown }> {
let response: Response;
try {
response = await fetchImpl(`${origin}/api/v1/users/${encodeURIComponent(identity)}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'User-Agent': 'mosaic-brain-owner/1',
},
redirect: 'manual',
signal: AbortSignal.timeout(5_000),
});
} catch {
throw new Error('owner-provider-unavailable');
}
return { status: response.status, user: await boundedJson(response) };
}
function publicIdentityMatches(value: unknown, identity: string): boolean {
const parsed = providerUserSchema.safeParse(value);
return parsed.success && parsed.data.login === identity;
}
export interface BrainOwnerPolicyBinding {
readonly brainNamespace: string;
readonly publicControl: string;
readonly privateControl: string;
readonly standingQueue: string;
}
export function resolveBrainOwnerPolicy(
ownerPolicySource: string,
estate: string,
): BrainOwnerPolicyBinding | undefined {
let rawPolicy: unknown;
try {
rawPolicy = JSON.parse(ownerPolicySource);
} catch {
return undefined;
}
const policy = ownerPolicySchema.safeParse(rawPolicy);
if (!policy.success) return undefined;
const estatePolicies = policy.data.estates.filter(
(candidate): boolean => candidate.estate === estate,
);
if (estatePolicies.length !== 1) return undefined;
const estatePolicy = estatePolicies[0];
if (estatePolicy === undefined || estatePolicy.laneArchiveOwners.length !== 1) return undefined;
const brainNamespace = estatePolicy.laneArchiveOwners[0]?.login;
if (brainNamespace === undefined || !exactCanonicalLogin(brainNamespace)) return undefined;
return {
brainNamespace,
publicControl: estatePolicy.controls.publicIdentity,
privateControl: estatePolicy.controls.privateIdentity,
standingQueue: estatePolicy.standingProcess.queue,
};
}
export function parseRequestedOwner(requestedOwner: string): string | null {
if (requestedOwner.normalize('NFKC') !== requestedOwner) return null;
const match = REQUESTED_OWNER.exec(requestedOwner);
const login = match?.[1];
if (login === undefined || !exactCanonicalLogin(login)) return null;
return login;
}
export async function resolveProviderDurableOwner(
input: {
readonly estateRegistrySource: string;
readonly ownerPolicySource: string;
readonly host: string;
readonly requestedOwner: string;
},
dependencies: {
readonly fetch: OwnerFetch;
readonly absentControlName: () => string;
},
): Promise<MigrationOwnerResolution> {
const requestedLogin = parseRequestedOwner(input.requestedOwner);
if (requestedLogin === null) return refused('owner-name-invalid');
const target = parseCredentialEstateRegistry(input.estateRegistrySource).resolveByHost(
input.host,
);
if (target === undefined) return refused('estate-host-unmapped');
const policy = resolveBrainOwnerPolicy(input.ownerPolicySource, target.estate);
if (policy === undefined) return refused('owner-policy-invalid');
if (policy.brainNamespace !== requestedLogin) return refused('owner-not-allowlisted');
const publicControl = policy.publicControl;
const privateControl = policy.privateControl;
const absentControl = dependencies.absentControlName();
if (
!exactCanonicalLogin(publicControl) ||
!exactCanonicalLogin(privateControl) ||
!exactCanonicalLogin(absentControl) ||
new Set([publicControl, privateControl, absentControl, requestedLogin]).size !== 4
) {
return refused('owner-policy-invalid');
}
try {
const publicResult = await readPublicIdentity(
target.host.apiBaseUrl,
publicControl,
dependencies.fetch,
);
if (publicResult.status !== 200 || !publicIdentityMatches(publicResult.user, publicControl)) {
return unresolved('owner-control-invalid');
}
const privateResult = await readPublicIdentity(
target.host.apiBaseUrl,
privateControl,
dependencies.fetch,
);
if (privateResult.status !== 404) return unresolved('owner-control-invalid');
const absentResult = await readPublicIdentity(
target.host.apiBaseUrl,
absentControl,
dependencies.fetch,
);
if (absentResult.status !== 404) return unresolved('owner-control-invalid');
const ownerResult = await readPublicIdentity(
target.host.apiBaseUrl,
requestedLogin,
dependencies.fetch,
);
if (ownerResult.status === 401 || ownerResult.status === 403 || ownerResult.status === 404) {
return unresolved('owner-not-resolvable');
}
if (ownerResult.status !== 200) return unresolved('owner-provider-unavailable');
if (!publicIdentityMatches(ownerResult.user, requestedLogin)) {
return unresolved('owner-provider-identity-mismatch');
}
return {
verdict: 'resolved',
reasonCode: 'owner-verified',
principal: { name: `user:${requestedLogin}`, kind: 'durable-human' },
authority: {
system: 'gitea',
endpoint: `GET /api/v1/users/${requestedLogin}`,
contentType: 'application/json',
},
};
} catch (error: unknown) {
const reason = error instanceof Error ? error.message : 'owner-provider-unavailable';
if (reason === 'owner-unexpected-content-type') return unresolved(reason);
if (reason === 'owner-unexpected-provider-shape') return unresolved(reason);
return unresolved('owner-provider-unavailable');
}
}
@@ -0,0 +1,122 @@
import { afterEach, describe, expect, it } from 'vitest';
import { Command } from 'commander';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
interface BrainProvisionCommandModule {
readonly BRAIN_PROVISION_COMMAND: string;
registerBrainProvisionCommand(program: Command): void;
executeBrainProvisionCommand(
options: {
readonly mosaicHome: string;
readonly home: string;
readonly identity: string;
readonly refusalIdentity: string;
readonly targetUrl: string;
readonly owner: string;
readonly lane: string;
readonly sourceRoot?: string;
readonly brainRoot?: string;
readonly ownerPolicy?: string;
readonly registry?: string;
},
dependencies: {
readonly run: () => never;
readonly fetch: typeof fetch;
readonly absentControlName: () => string;
},
): Promise<{
readonly status: 'provisioned' | 'blocked' | 'failed';
readonly reasonCode: string;
}>;
}
const MODULE_PATH = './brain-provision-command.js';
const roots: string[] = [];
async function loadCommand(requirement: string): Promise<BrainProvisionCommandModule> {
try {
return (await import(MODULE_PATH)) as BrainProvisionCommandModule;
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`${requirement}: brain provision command is absent (${detail})`);
}
}
function tempRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-command-'));
roots.push(root);
return root;
}
afterEach((): void => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
describe('internal P7 brain provision command', (): void => {
it('registers only explicit non-secret contract inputs and no credential/token lookup switches', async (): Promise<void> => {
const module = await loadCommand('MB-REQ-03 broker-only provision command');
const program = new Command();
module.registerBrainProvisionCommand(program);
const command = program.commands.find(
(candidate) => candidate.name() === module.BRAIN_PROVISION_COMMAND,
);
expect(command).toBeDefined();
const flags = command?.options.map((option) => option.flags) ?? [];
expect(flags.join(' ')).toContain('--identity');
expect(flags.join(' ')).toContain('--target-url');
expect(flags.join(' ')).toContain('--refusal-identity');
expect(flags.join(' ')).toContain('--owner-policy');
expect(flags.join(' ')).toContain('--owner');
expect(flags.join(' ')).toContain('--lane');
expect(flags.join(' ')).not.toMatch(/token|password|authorization|grant-authority/i);
});
it('is registered by the shipped CLI', async (): Promise<void> => {
const module = await loadCommand('MB-REQ-10 shipped P7 command');
const cli = readFileSync(join(process.cwd(), 'src', 'cli.ts'), 'utf8');
expect(cli).toContain('registerBrainProvisionCommand');
expect(cli).toContain(`registerBrainProvisionCommand(program)`);
expect(module.BRAIN_PROVISION_COMMAND).toBe('__brain-provision');
});
it('fails closed before commands when the local owner policy is absent', async (): Promise<void> => {
const module = await loadCommand('MB-REQ-09 owner policy required');
const root = tempRoot();
const home = join(root, 'home');
const mosaicHome = join(home, '.config', 'mosaic');
mkdirSync(join(mosaicHome, 'cred'), { recursive: true });
writeFileSync(
join(mosaicHome, 'cred', 'estates.json'),
JSON.stringify({ version: 1, estates: [] }),
{ mode: 0o600 },
);
let commands = 0;
const result = await module.executeBrainProvisionCommand(
{
mosaicHome,
home,
identity: 'seat-a',
refusalIdentity: 'outside-seat',
targetUrl: 'https://git.example.invalid/example/stack.git',
owner: 'user:durable-owner',
lane: 'lane-a',
},
{
run: (): never => {
commands += 1;
throw new Error('must not run');
},
fetch,
absentControlName: (): string => 'generated-absent-control',
},
);
expect(result).toMatchObject({ status: 'failed', reasonCode: 'owner-policy-unavailable' });
expect(commands).toBe(0);
});
});
@@ -0,0 +1,133 @@
import { randomUUID } from 'node:crypto';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { Command } from 'commander';
import { readBrainConfigSecure } from './brain-secure-config.js';
import { provisionBrain, type ProvisionResult } from './brain-provision.js';
import { systemCommandRunner, type CommandRunner } from './brain-store-runtime.js';
import type { OwnerFetch } from './brain-owner-resolver.js';
export const BRAIN_PROVISION_COMMAND = '__brain-provision';
interface BrainProvisionCommandOptions {
readonly mosaicHome: string;
readonly home: string;
readonly identity: string;
readonly refusalIdentity: string;
readonly targetUrl: string;
readonly owner: string;
readonly lane: string;
readonly sourceRoot?: string;
readonly brainRoot?: string;
readonly ownerPolicy?: string;
readonly registry?: string;
}
interface BrainProvisionCommandDependencies {
readonly run: CommandRunner;
readonly fetch: OwnerFetch;
readonly absentControlName: () => string;
}
function configFailure(reasonCode: string): ProvisionResult {
return {
status: 'failed',
reasonCode,
findings: [{ code: `brain-${reasonCode}`, reasonCode }],
owner: null,
migration: null,
};
}
export async function executeBrainProvisionCommand(
options: BrainProvisionCommandOptions,
dependencies: BrainProvisionCommandDependencies,
): Promise<ProvisionResult> {
const registry = options.registry ?? join(options.mosaicHome, 'cred', 'estates.json');
const ownerPolicy = options.ownerPolicy ?? join(options.mosaicHome, 'brain', 'owners.json');
let estateRegistrySource: string;
try {
estateRegistrySource = readBrainConfigSecure(registry, options.mosaicHome);
} catch {
return configFailure('estate-registry-unavailable');
}
let ownerPolicySource: string;
try {
ownerPolicySource = readBrainConfigSecure(ownerPolicy, options.mosaicHome);
} catch {
return configFailure('owner-policy-unavailable');
}
try {
return await provisionBrain(
{
estateRegistrySource,
ownerPolicySource,
targetGitUrl: options.targetUrl,
requestedOwner: options.owner,
identity: options.identity,
refusalIdentity: options.refusalIdentity,
root: options.brainRoot ?? join(options.home, '.mosaic'),
sourceRoot: options.sourceRoot ?? join(options.mosaicHome, 'memory'),
seat: options.identity,
lane: options.lane,
laneActive: false,
},
dependencies,
);
} catch {
return configFailure('brain-provision-exception');
}
}
export function registerBrainProvisionCommand(program: Command): void {
program
.command(BRAIN_PROVISION_COMMAND, { hidden: true })
.description('Internal installer P7 durable-brain provisioner')
.requiredOption('--identity <name>', 'explicit fleet identity')
.requiredOption('--target-url <url>', 'configured target git URL')
.requiredOption('--refusal-identity <name>', 'explicit out-of-estate negative control')
.requiredOption('--owner <owner>', 'policy-bound durable owner candidate')
.requiredOption('--lane <name>', 'source lane to migrate')
.option('--mosaic-home <path>', 'installed Mosaic home')
.option('--home <path>', 'principal home')
.option('--source-root <path>', 'legacy memory root')
.option('--brain-root <path>', 'per-estate brain checkout root')
.option('--owner-policy <path>', 'durable-owner allowlist policy')
.option('--registry <path>', 'estate registry path')
.action(async (raw: Record<string, string | undefined>): Promise<void> => {
const home = raw['home'] ?? homedir();
const mosaicHome =
raw['mosaicHome'] ?? process.env['MOSAIC_HOME'] ?? join(home, '.config', 'mosaic');
const result = await executeBrainProvisionCommand(
{
mosaicHome,
home,
identity: raw['identity']!,
targetUrl: raw['targetUrl']!,
refusalIdentity: raw['refusalIdentity']!,
owner: raw['owner']!,
lane: raw['lane']!,
...(raw['sourceRoot'] === undefined ? {} : { sourceRoot: raw['sourceRoot'] }),
...(raw['brainRoot'] === undefined ? {} : { brainRoot: raw['brainRoot'] }),
...(raw['ownerPolicy'] === undefined ? {} : { ownerPolicy: raw['ownerPolicy'] }),
...(raw['registry'] === undefined ? {} : { registry: raw['registry'] }),
},
{
run: systemCommandRunner,
fetch,
absentControlName: (): string => `mosaic-absent-${randomUUID()}`,
},
);
process.stdout.write(
`${JSON.stringify({
status: result.status,
reasonCode: result.reasonCode,
findings: result.findings,
owner: result.owner,
migration: result.migration,
})}\n`,
);
if (result.status !== 'provisioned') process.exitCode = result.status === 'blocked' ? 30 : 20;
});
}
@@ -0,0 +1,561 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
interface CommandRequest {
readonly program: 'git' | 'mosaic';
readonly args: readonly string[];
readonly env: Readonly<Record<string, string>>;
}
interface CommandResult {
readonly status: number;
readonly stdout: string;
readonly stderr: string;
}
type CommandRunner = (request: CommandRequest) => CommandResult;
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
interface ProvisionResult {
readonly status: 'provisioned' | 'blocked' | 'failed';
readonly reasonCode: string;
readonly findings: readonly { code: string; reasonCode: string | null }[];
readonly owner: {
readonly verdict: 'resolved' | 'refused' | 'not-measured';
readonly reasonCode: string;
} | null;
readonly migration: {
readonly status: 'migrated' | 'reported' | 'failed';
readonly reported: readonly { path: string; reason: string }[];
} | null;
}
interface ProvisionModule {
provisionBrain(
input: {
readonly estateRegistrySource: string;
readonly ownerPolicySource: string;
readonly targetGitUrl: string;
readonly requestedOwner: string;
readonly identity: string;
readonly refusalIdentity: string;
readonly root: string;
readonly sourceRoot: string;
readonly seat: string;
readonly lane: string;
readonly laneActive: boolean;
},
dependencies: {
readonly run: CommandRunner;
readonly fetch: FetchLike;
readonly absentControlName: () => string;
readonly approveMigrationContent?: (path: string, content: Uint8Array) => boolean;
},
): Promise<ProvisionResult>;
}
const MODULE_PATH = './brain-provision.js';
const roots: string[] = [];
async function loadProvisioner(requirement: string): Promise<ProvisionModule> {
try {
return (await import(MODULE_PATH)) as ProvisionModule;
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`${requirement}: brain provisioner is absent (${detail})`);
}
}
function tempRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-provision-'));
roots.push(root);
return root;
}
function estateRegistry(): string {
return JSON.stringify({
version: 1,
estates: [
{
name: 'homelab',
readOnlyControlIdentity: 'read-control',
hosts: [
{
host: 'git.example.invalid',
provider: 'gitea',
apiBaseUrl: 'https://git.example.invalid',
tokenPrefix: 'gitea-example',
},
],
},
],
});
}
function ownerPolicy(): string {
return JSON.stringify({
version: 1,
estates: [
{
estate: 'homelab',
laneArchiveOwners: [{ kind: 'provider-user', login: 'durable-owner' }],
standingProcess: { kind: 'glpi-queue', queue: 'mosaic-brain-remediation' },
controls: { publicIdentity: 'public-control', privateIdentity: 'private-control' },
},
],
});
}
function validateResult(
outcome: 'ok' | 'refused' | 'indeterminate',
reasonCode: string,
identity = 'seat-a',
): string {
const exitCode = outcome === 'ok' ? 0 : outcome === 'refused' ? 10 : 30;
return JSON.stringify({
schemaVersion: 1,
operation: 'validate',
outcome,
exitCode,
retryable: false,
subject: {
identity,
estate: 'homelab',
host: 'git.example.invalid',
repo: 'durable-owner/mosaic-brain',
},
mutation: 'none',
reason: { code: reasonCode, message: 'non-secret' },
evidence: {
providerIdentity:
outcome === 'ok'
? {
login: identity,
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
}
: null,
repositoryPermission:
outcome === 'ok'
? {
requested: 'write',
effective: 'write',
endpoint: 'GET /api/v1/repos/durable-owner/mosaic-brain',
contentType: 'application/json',
}
: null,
writeDifferential:
outcome === 'ok'
? {
state: 'can-write',
credentialBinding: 'same-resolution',
transportPrincipal: identity,
authenticatedReceivePack: 'advertised',
readOnlyControl: {
identity: 'read-control',
providerPermission: 'read',
receivePack: 'refused',
},
unauthenticatedReceivePack: 'refused',
artifactCreated: false,
proves: 'non-secret evidence',
doesNotProve: 'branch update acceptance',
}
: null,
},
audit: { journalId: 'opaque', state: 'sealed' },
});
}
function publicUser(login: string): Response {
return new Response(JSON.stringify({ id: 1, login, visibility: 'public', active: false }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
function ownerFetch(ownerStatus = 200): FetchLike {
return async (input): Promise<Response> => {
const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const identity = decodeURIComponent(new URL(raw).pathname.split('/').at(-1) ?? '');
if (identity === 'public-control') return publicUser(identity);
if (identity === 'private-control' || identity === 'generated-absent-control') {
return new Response(JSON.stringify({ message: 'hidden or absent' }), {
status: 404,
headers: { 'content-type': 'application/json' },
});
}
if (identity === 'durable-owner' && ownerStatus === 200) return publicUser(identity);
return new Response(JSON.stringify({ message: 'hidden or absent' }), {
status: ownerStatus,
headers: { 'content-type': 'application/json' },
});
};
}
function baseInput(root: string): {
readonly estateRegistrySource: string;
readonly ownerPolicySource: string;
readonly targetGitUrl: string;
readonly requestedOwner: string;
readonly identity: string;
readonly refusalIdentity: string;
readonly root: string;
readonly sourceRoot: string;
readonly seat: string;
readonly lane: string;
readonly laneActive: boolean;
} {
return {
estateRegistrySource: estateRegistry(),
ownerPolicySource: ownerPolicy(),
targetGitUrl: 'https://git.example.invalid/example/stack.git',
requestedOwner: 'user:durable-owner',
identity: 'seat-a',
refusalIdentity: 'outside-seat',
root: join(root, 'brain'),
sourceRoot: join(root, 'local-memory'),
seat: 'seat-a',
lane: 'lane-a',
laneActive: false,
};
}
afterEach((): void => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
describe('P7 brain provisioning orchestration', (): void => {
it('requires the P5 write-capability postcondition and never grants or clones on refusal', async (): Promise<void> => {
const provisioner = await loadProvisioner('MB-REQ-10 P5 before P7');
const root = tempRoot();
const requests: CommandRequest[] = [];
const result = await provisioner.provisionBrain(baseInput(root), {
run: (request): CommandResult => {
requests.push(request);
return {
status: 10,
stdout: validateResult('refused', 'no-token-for-identity'),
stderr: 'refused reason=no-token-for-identity',
};
},
fetch: ownerFetch(),
absentControlName: (): string => 'generated-absent-control',
});
expect(result).toMatchObject({
status: 'blocked',
reasonCode: 'credential-postcondition-failed',
});
expect(requests).toHaveLength(1);
expect(requests[0]?.program).toBe('mosaic');
expect(requests[0]?.args.slice(0, 3)).toEqual(['cred', 'validate', 'seat-a']);
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
expect(requests.some((request) => request.args.includes('clone'))).toBe(false);
});
it('blocks before clone when the out-of-estate Git and API axes disagree', async (): Promise<void> => {
const provisioner = await loadProvisioner('MB-REQ-05 P7 refusal control gate');
const root = tempRoot();
const requests: CommandRequest[] = [];
const result = await provisioner.provisionBrain(baseInput(root), {
run: (request): CommandResult => {
requests.push(request);
if (request.program === 'mosaic' && request.args[2] === 'outside-seat') {
return {
status: 10,
stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'),
stderr: 'refused reason=no-token-for-identity',
};
}
if (request.program === 'mosaic') {
return { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' };
}
if (request.args.includes('ls-remote')) {
return { status: 0, stdout: 'refs are visible', stderr: '' };
}
return { status: 99, stdout: '', stderr: 'unexpected command' };
},
fetch: ownerFetch(),
absentControlName: (): string => 'generated-absent-control',
});
expect(result).toMatchObject({
status: 'blocked',
reasonCode: 'refusal-control-failed',
});
expect(requests.some((request) => request.args.includes('clone'))).toBe(false);
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
});
it('blocks before skeleton publication when the existing checkout is dirty', async (): Promise<void> => {
const provisioner = await loadProvisioner('MB-REQ-06 dirty checkout publication gate');
const root = tempRoot();
const input = baseInput(root);
mkdirSync(join(input.root, '.git'), { recursive: true });
const requests: CommandRequest[] = [];
const result = await provisioner.provisionBrain(input, {
run: (request): CommandResult => {
requests.push(request);
if (request.program === 'mosaic') {
return request.args[2] === 'outside-seat'
? {
status: 10,
stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'),
stderr: 'refused reason=no-token-for-identity',
}
: { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' };
}
const command = request.args.join(' ');
if (command.includes('ls-remote')) {
return {
status: 128,
stdout: '',
stderr: 'credential helper refused reason=no-token-for-identity',
};
}
if (command.includes('rev-parse --is-inside-work-tree')) {
return { status: 0, stdout: 'true\n', stderr: '' };
}
if (command.includes('remote get-url origin')) {
return {
status: 0,
stdout: 'https://git.example.invalid/durable-owner/mosaic-brain.git\n',
stderr: '',
};
}
if (command.includes('branch --show-current')) {
return { status: 0, stdout: 'main\n', stderr: '' };
}
if (command.includes('status --porcelain')) {
return { status: 0, stdout: '?? .gitignore\n', stderr: '' };
}
return { status: 99, stdout: '', stderr: 'unexpected publication command' };
},
fetch: ownerFetch(),
absentControlName: (): string => 'generated-absent-control',
});
expect(result).toMatchObject({
status: 'blocked',
reasonCode: 'brain-postcondition-failed',
});
expect(requests.some((request) => request.args.includes('commit'))).toBe(false);
expect(requests.some((request) => request.args.includes('push'))).toBe(false);
});
it('clones, seeds, resolves owner, migrates, pushes on each write, and archives source only after reachability', async (): Promise<void> => {
const provisioner = await loadProvisioner('MB-REQ-07 complete migration transaction');
const root = tempRoot();
const input = baseInput(root);
mkdirSync(join(input.sourceRoot, 'lanes', 'lane-a'), { recursive: true });
const source = join(input.sourceRoot, 'lanes', 'lane-a', 'finding.md');
writeFileSync(source, 'durable finding\n');
const requests: CommandRequest[] = [];
let commitOrdinal = 0;
let approvedPaths: string[] = [];
let privateAtClone = false;
const runner: CommandRunner = (request): CommandResult => {
requests.push(request);
if (request.program === 'mosaic') {
if (request.args[2] === 'outside-seat') {
return {
status: 10,
stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'),
stderr: 'refused reason=no-token-for-identity',
};
}
return { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' };
}
const command = request.args.join(' ');
if (command.includes('ls-remote')) {
return {
status: 128,
stdout: '',
stderr: 'credential helper refused reason=no-token-for-identity',
};
}
if (request.args[0] === 'clone') {
privateAtClone = existsSync(input.root) && (lstatSync(input.root).mode & 0o077) === 0;
mkdirSync(join(input.root, '.git'), { recursive: true });
return { status: 0, stdout: '', stderr: '' };
}
if (command.includes('read-tree')) {
approvedPaths = [];
return { status: 0, stdout: '', stderr: '' };
}
if (command.includes('hash-object')) {
return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' };
}
if (command.includes('update-index')) {
const path = request.args.at(-1);
if (path !== undefined) approvedPaths.push(path);
return { status: 0, stdout: '', stderr: '' };
}
if (command.includes('rev-parse') && request.args.at(-1)?.includes(':')) {
return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' };
}
if (command.includes('diff --cached --quiet')) {
return { status: 1, stdout: '', stderr: '' };
}
if (command.includes('diff-tree')) {
return { status: 0, stdout: `${approvedPaths.join('\0')}\0`, stderr: '' };
}
if (command.includes('show -s')) {
return {
status: 0,
stdout: 'seat-a\[email protected]\0seat-a\[email protected]\n',
stderr: '',
};
}
if (command.includes('rev-parse --is-inside-work-tree')) {
return { status: 0, stdout: 'true\n', stderr: '' };
}
if (command.includes('remote get-url origin')) {
return {
status: 0,
stdout: 'https://git.example.invalid/durable-owner/mosaic-brain.git\n',
stderr: '',
};
}
if (command.includes('branch --show-current')) {
return { status: 0, stdout: 'main\n', stderr: '' };
}
if (command.includes('status --porcelain')) {
return { status: 0, stdout: '', stderr: '' };
}
if (command.includes('rev-parse HEAD')) {
commitOrdinal += 1;
return {
status: 0,
stdout: `${commitOrdinal === 1 ? 'a' : 'c'.repeat(1)}`.repeat(40) + '\n',
stderr: '',
};
}
if (command.includes('rev-parse origin/main')) {
const value = commitOrdinal === 1 ? 'b' : 'd';
return { status: 0, stdout: `${value.repeat(40)}\n`, stderr: '' };
}
return { status: 0, stdout: '', stderr: '' };
};
const result = await provisioner.provisionBrain(input, {
run: runner,
fetch: ownerFetch(),
absentControlName: (): string => 'generated-absent-control',
approveMigrationContent: (): boolean => true,
});
expect(result).toMatchObject({
status: 'provisioned',
reasonCode: 'brain-provisioned',
owner: { verdict: 'resolved', reasonCode: 'owner-verified' },
migration: { status: 'reported' },
});
expect(privateAtClone).toBe(true);
expect(existsSync(source)).toBe(true);
const imported = result.migration?.reported ?? [];
expect(imported).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: source, reason: expect.stringMatching(/retained/i) }),
]),
);
const laneImports = join(input.root, 'lanes', 'lane-a', 'findings', 'imports');
const archiveImports = join(input.root, 'archives', 'imports', 'lane');
expect(existsSync(laneImports)).toBe(true);
expect(existsSync(archiveImports)).toBe(true);
expect(
requests.filter((request) => request.program === 'git' && request.args.includes('push')),
).toHaveLength(2);
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
});
it('keeps every source and reports the owner ambiguity when the public owner cannot be resolved', async (): Promise<void> => {
const provisioner = await loadProvisioner('MB-REQ-07 owner-blocked detection/reporting');
const root = tempRoot();
const input = baseInput(root);
mkdirSync(input.root, { recursive: true });
mkdirSync(join(input.root, '.git'), { recursive: true });
mkdirSync(join(input.sourceRoot, 'lanes', 'lane-a'), { recursive: true });
const source = join(input.sourceRoot, 'lanes', 'lane-a', 'finding.md');
writeFileSync(source, 'retain me\n');
let commitOrdinal = 0;
const result = await provisioner.provisionBrain(input, {
run: (request): CommandResult => {
if (request.program === 'mosaic') {
if (request.args[2] === 'outside-seat') {
return {
status: 10,
stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'),
stderr: 'refused reason=no-token-for-identity',
};
}
return { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' };
}
const command = request.args.join(' ');
if (command.includes('ls-remote')) {
return {
status: 128,
stdout: '',
stderr: 'credential helper refused reason=no-token-for-identity',
};
}
if (command.includes('rev-parse --is-inside-work-tree')) {
return { status: 0, stdout: 'true\n', stderr: '' };
}
if (command.includes('remote get-url origin')) {
return {
status: 0,
stdout: 'https://git.example.invalid/durable-owner/mosaic-brain.git\n',
stderr: '',
};
}
if (command.includes('branch --show-current')) {
return { status: 0, stdout: 'main\n', stderr: '' };
}
if (command.includes('status --porcelain')) {
return { status: 0, stdout: '', stderr: '' };
}
if (command.includes('rev-parse HEAD')) {
commitOrdinal += 1;
return { status: 0, stdout: `${'a'.repeat(40)}\n`, stderr: '' };
}
if (command.includes('rev-parse origin/main')) {
return { status: 0, stdout: `${'b'.repeat(40)}\n`, stderr: '' };
}
return { status: 0, stdout: '', stderr: '' };
},
fetch: ownerFetch(404),
absentControlName: (): string => 'generated-absent-control',
});
expect(result).toMatchObject({
status: 'blocked',
reasonCode: 'owner-not-resolvable',
owner: { verdict: 'not-measured', reasonCode: 'owner-not-resolvable' },
migration: { status: 'reported' },
});
expect(readFileSync(source, 'utf8')).toBe('retain me\n');
expect(result.migration?.reported).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: source, reason: expect.stringMatching(/owner/i) }),
]),
);
expect(JSON.stringify(result)).not.toMatch(/owner-not-found|does-not-exist/);
expect(commitOrdinal).toBe(0);
});
});
@@ -0,0 +1,271 @@
import { existsSync } from 'node:fs';
import { parseRequestedOwner, resolveProviderDurableOwner } from './brain-owner-resolver.js';
import {
createBrainSkeleton,
deriveBrainTarget,
discoverBrainMigration,
ensureBrainRootPrivate,
migrateBrainState,
type MigrationResult,
type MigrationOwnerResolution,
type MigrationPublishEntry,
} from './brain-store.js';
import {
collectBrainDoctorReport,
collectBrainRefusalControl,
publishBrainPaths,
type CommandRequest,
type CommandResult,
type CommandRunner,
} from './brain-store-runtime.js';
import type { OwnerFetch } from './brain-owner-resolver.js';
export interface ProvisionResult {
readonly status: 'provisioned' | 'blocked' | 'failed';
readonly reasonCode: string;
readonly findings: readonly {
readonly code: string;
readonly reasonCode: string | null;
}[];
readonly owner: Pick<MigrationOwnerResolution, 'verdict' | 'reasonCode'> | null;
readonly migration: Pick<MigrationResult, 'status' | 'reported'> | null;
}
function commandEnv(identity: string): Readonly<Record<string, string>> {
return { MOSAIC_GIT_IDENTITY: identity, GIT_TERMINAL_PROMPT: '0' };
}
function findingView(
findings: readonly { readonly code: string; readonly reasonCode: string | null }[],
): readonly { readonly code: string; readonly reasonCode: string | null }[] {
return findings.map((finding): { readonly code: string; readonly reasonCode: string | null } => ({
code: finding.code,
reasonCode: finding.reasonCode,
}));
}
function blocked(
reasonCode: string,
findings: readonly { readonly code: string; readonly reasonCode: string | null }[],
owner: MigrationOwnerResolution | null = null,
migration: MigrationResult | null = null,
): ProvisionResult {
return {
status: 'blocked',
reasonCode,
findings: findingView(findings),
owner: owner === null ? null : { verdict: owner.verdict, reasonCode: owner.reasonCode },
migration:
migration === null ? null : { status: migration.status, reported: migration.reported },
};
}
function failed(
reasonCode: string,
findings: readonly { readonly code: string; readonly reasonCode: string | null }[],
owner: MigrationOwnerResolution | null = null,
migration: MigrationResult | null = null,
): ProvisionResult {
return {
...blocked(reasonCode, findings, owner, migration),
status: 'failed',
};
}
export async function provisionBrain(
input: {
readonly estateRegistrySource: string;
readonly ownerPolicySource: string;
readonly targetGitUrl: string;
readonly requestedOwner: string;
readonly identity: string;
readonly refusalIdentity: string;
readonly root: string;
readonly sourceRoot: string;
readonly seat: string;
readonly lane: string;
readonly laneActive: boolean;
},
dependencies: {
readonly run: CommandRunner;
readonly fetch: OwnerFetch;
readonly absentControlName: () => string;
readonly approveMigrationContent?: (path: string, content: Uint8Array) => boolean;
},
): Promise<ProvisionResult> {
const brainNamespace = parseRequestedOwner(input.requestedOwner);
if (brainNamespace === null) return blocked('owner-name-invalid', []);
const target = deriveBrainTarget(input.estateRegistrySource, input.targetGitUrl, brainNamespace);
if (existsSync(input.root)) {
try {
ensureBrainRootPrivate(input.root);
} catch {
return blocked('brain-root-permissions-unsafe', []);
}
}
const doctorInput = {
registrySource: input.estateRegistrySource,
targetGitUrl: input.targetGitUrl,
brainNamespace,
identity: input.identity,
root: input.root,
};
let report = collectBrainDoctorReport(doctorInput, dependencies.run);
if (report.access.outcome !== 'ok') {
return blocked('credential-postcondition-failed', report.findings);
}
const refusalControl = collectBrainRefusalControl(
{
registrySource: input.estateRegistrySource,
targetGitUrl: input.targetGitUrl,
brainNamespace,
refusalIdentity: input.refusalIdentity,
},
dependencies.run,
);
if (!refusalControl.observed) {
return blocked('refusal-control-failed', [
...report.findings,
{
code: 'brain-refusal-control-indeterminate',
reasonCode: refusalControl.reasonCode,
},
]);
}
const owner = await resolveProviderDurableOwner(
{
estateRegistrySource: input.estateRegistrySource,
ownerPolicySource: input.ownerPolicySource,
host: target.host,
requestedOwner: input.requestedOwner,
},
{
fetch: dependencies.fetch,
absentControlName: dependencies.absentControlName,
},
);
if (owner.verdict !== 'resolved') {
const plan = discoverBrainMigration(
{
sourceRoot: input.sourceRoot,
brainRoot: input.root,
seat: input.seat,
lane: input.lane,
laneActive: input.laneActive,
},
(): MigrationOwnerResolution => owner,
);
const migration = migrateBrainState(
plan,
(): never => {
throw new Error('blocked owner cannot publish');
},
input.root,
);
return blocked(owner.reasonCode, report.findings, owner, migration);
}
if (report.findings.some((finding): boolean => finding.code === 'brain-clone-missing')) {
try {
ensureBrainRootPrivate(input.root);
} catch {
return failed('brain-root-permissions-unsafe', report.findings);
}
const clone: CommandRequest = {
program: 'git',
args: ['clone', '--branch', 'main', '--single-branch', target.cloneUrl, input.root],
env: commandEnv(input.identity),
};
const cloneResult: CommandResult = dependencies.run(clone);
if (cloneResult.status !== 0) return failed('brain-clone-failed', report.findings);
try {
ensureBrainRootPrivate(input.root);
} catch {
return failed('brain-root-permissions-unsafe', report.findings);
}
report = collectBrainDoctorReport(doctorInput, dependencies.run);
}
const blockingCloneFindings = report.findings.filter(
(finding): boolean =>
finding.code === 'brain-clone-missing' ||
finding.code === 'brain-not-git-repository' ||
finding.code === 'brain-remote-mismatch' ||
finding.code === 'brain-branch-mismatch' ||
finding.code === 'brain-uncommitted-state' ||
finding.code === 'brain-git-state-indeterminate' ||
finding.code === 'brain-root-permissions-unsafe' ||
finding.code.startsWith('brain-write-access-'),
);
if (blockingCloneFindings.length > 0) {
return blocked('brain-postcondition-failed', report.findings);
}
let skeletonEntries: readonly MigrationPublishEntry[];
try {
const skeleton = createBrainSkeleton(input.root);
skeletonEntries = skeleton.publicationEntries;
} catch {
return failed('brain-skeleton-failed', report.findings);
}
if (skeletonEntries.length > 0) {
try {
const evidence = publishBrainPaths(
{
root: input.root,
identity: input.identity,
entries: skeletonEntries,
message: 'chore: seed durable brain layout',
},
dependencies.run,
);
if (!evidence.reachable) return failed('brain-skeleton-not-reachable', report.findings);
} catch {
return failed('brain-skeleton-publish-failed', report.findings);
}
}
const plan = discoverBrainMigration(
{
sourceRoot: input.sourceRoot,
brainRoot: input.root,
seat: input.seat,
lane: input.lane,
laneActive: input.laneActive,
},
(): MigrationOwnerResolution => owner,
dependencies.approveMigrationContent,
);
const migration = migrateBrainState(
plan,
(brainRoot: string, entries: readonly MigrationPublishEntry[]) =>
publishBrainPaths(
{
root: brainRoot,
identity: input.identity,
entries,
message: `migrate: archive ${input.lane} working memory`,
},
dependencies.run,
),
input.root,
);
if (migration.status === 'failed') {
return failed('brain-migration-publish-failed', report.findings, owner, migration);
}
report = collectBrainDoctorReport(doctorInput, dependencies.run);
if (report.findings.length > 0) {
return blocked('brain-final-postcondition-failed', report.findings, owner, migration);
}
return {
status: 'provisioned',
reasonCode: 'brain-provisioned',
findings: [],
owner: { verdict: owner.verdict, reasonCode: owner.reasonCode },
migration: { status: migration.status, reported: migration.reported },
};
}
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
interface SecureConfigModule {
readBrainConfigSecure(path: string, root: string): string;
}
const roots: string[] = [];
async function loadSecureConfig(): Promise<SecureConfigModule> {
try {
return (await import('./brain-secure-config.js')) as SecureConfigModule;
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`MB-REQ-06 secure brain config reader is absent (${detail})`);
}
}
function fixture(): { readonly root: string; readonly directory: string; readonly file: string } {
const outer = mkdtempSync(join(tmpdir(), 'mosaic-brain-secure-config-'));
roots.push(outer);
const root = join(outer, 'mosaic');
const directory = join(root, 'brain');
const file = join(directory, 'owners.json');
mkdirSync(directory, { recursive: true, mode: 0o700 });
writeFileSync(file, '{"version":1}\n', { mode: 0o600 });
return { root, directory, file };
}
afterEach((): void => {
vi.restoreAllMocks();
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
describe('security-critical brain configuration reads', (): void => {
it('reads a principal-owned non-writable regular file through the secure descriptor path', async (): Promise<void> => {
const secure = await loadSecureConfig();
const config = fixture();
expect(secure.readBrainConfigSecure(config.file, config.root)).toBe('{"version":1}\n');
});
it('rejects a managed root owned by a UID other than the running principal', async (): Promise<void> => {
const secure = await loadSecureConfig();
const config = fixture();
if (typeof process.getuid !== 'function') throw new Error('test requires POSIX getuid');
const processWithUid = process as typeof process & { getuid: () => number };
const actualUid = processWithUid.getuid();
vi.spyOn(processWithUid, 'getuid').mockReturnValue(actualUid + 1);
expect(() => secure.readBrainConfigSecure(config.file, config.root)).toThrow(
/config-ancestor-owner-unsafe/,
);
});
it('rejects a group/world-writable policy file', async (): Promise<void> => {
const secure = await loadSecureConfig();
const config = fixture();
chmodSync(config.file, 0o666);
expect(() => secure.readBrainConfigSecure(config.file, config.root)).toThrow(
/config-file-permissions-unsafe/,
);
});
it('rejects a group/world-writable managed ancestor', async (): Promise<void> => {
const secure = await loadSecureConfig();
const config = fixture();
chmodSync(config.directory, 0o777);
expect(() => secure.readBrainConfigSecure(config.file, config.root)).toThrow(
/config-ancestor-permissions-unsafe/,
);
});
});
@@ -0,0 +1,52 @@
import { lstatSync } from 'node:fs';
import { dirname, relative, resolve, sep } from 'node:path';
import { assertCanonicalContainment, readRegularFileSecure } from '../fleet/secure-file.js';
const MAX_CONFIG_BYTES = 256 * 1024;
const GROUP_OR_OTHER_WRITE = 0o022;
function currentUid(): number {
if (typeof process.getuid !== 'function') {
throw new Error('config-owner-check-unsupported');
}
return process.getuid();
}
function assertOwnedNonWritableDirectory(path: string, uid: number): void {
const status = lstatSync(path);
if (!status.isDirectory() || status.isSymbolicLink() || status.uid !== uid) {
throw new Error('config-ancestor-owner-unsafe');
}
if ((status.mode & GROUP_OR_OTHER_WRITE) !== 0) {
throw new Error('config-ancestor-permissions-unsafe');
}
}
export function readBrainConfigSecure(path: string, root: string): string {
const canonicalRoot = resolve(root);
const canonicalPath = resolve(path);
assertCanonicalContainment(canonicalRoot, canonicalPath);
const uid = currentUid();
assertOwnedNonWritableDirectory(canonicalRoot, uid);
let cursor = canonicalRoot;
for (const component of relative(canonicalRoot, dirname(canonicalPath))
.split(sep)
.filter(Boolean)) {
cursor = resolve(cursor, component);
assertOwnedNonWritableDirectory(cursor, uid);
}
const snapshot = readRegularFileSecure(canonicalPath, {
root: canonicalRoot,
maxBytes: MAX_CONFIG_BYTES,
});
if (snapshot.uid !== uid) throw new Error('config-file-owner-unsafe');
if ((snapshot.mode & GROUP_OR_OTHER_WRITE) !== 0) {
throw new Error('config-file-permissions-unsafe');
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(snapshot.content);
} catch {
throw new Error('config-file-not-utf8');
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,569 @@
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
import {
assessCredentialResult,
brainRootIsPrivate,
deriveBrainTarget,
ensureBrainRootPrivate,
evaluateBrainDoctor,
planBrainDoctorFix,
type BrainDoctorFinding,
type BrainDoctorObservation,
type CredentialAssessment,
} from './brain-store.js';
const COMMIT = /^[0-9a-f]{40}$/;
const GIT_OBJECT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
const MAX_PUBLISH_ENTRY_BYTES = 1024 * 1024;
export interface CommandRequest {
readonly program: 'git' | 'mosaic';
readonly args: readonly string[];
readonly cwd?: string;
readonly env: Readonly<Record<string, string>>;
readonly stdin?: Uint8Array;
}
export interface CommandResult {
readonly status: number;
readonly stdout: string;
readonly stderr: string;
}
export type CommandRunner = (request: CommandRequest) => CommandResult;
export const systemCommandRunner: CommandRunner = (request: CommandRequest): CommandResult => {
const result = spawnSync(request.program, request.args, {
cwd: request.cwd,
env: { ...process.env, ...request.env },
encoding: 'utf8',
maxBuffer: 1024 * 1024,
input: request.stdin,
});
return {
status: result.status ?? 127,
stdout: result.stdout ?? '',
stderr: result.stderr ?? result.error?.message ?? '',
};
};
export interface DoctorRuntimeReport {
readonly findings: readonly BrainDoctorFinding[];
readonly access: CredentialAssessment;
readonly refusalControl: {
readonly observed: boolean;
readonly reasonCode: string | null;
};
}
export interface BrainRefusalControlResult {
readonly observed: boolean;
readonly reasonCode: string | null;
readonly gitReasonCode: string;
readonly apiReasonCode: string;
}
export interface PublishEvidence {
readonly commit: string;
readonly remoteHead: string;
readonly reachable: boolean;
}
function commandEnv(identity: string): Readonly<Record<string, string>> {
return {
MOSAIC_GIT_IDENTITY: identity,
GIT_TERMINAL_PROMPT: '0',
};
}
function integrationFailure(): CredentialAssessment {
return {
outcome: 'indeterminate',
exitCode: 30,
reasonCode: 'unexpected-provider-shape',
diagnostic: 'indeterminate: unexpected-provider-shape',
};
}
function runGit(run: CommandRunner, identity: string, args: readonly string[]): CommandResult {
return run({ program: 'git', args, env: commandEnv(identity) });
}
function runGitWithEnv(
run: CommandRunner,
identity: string,
args: readonly string[],
env: Readonly<Record<string, string>>,
stdin?: Uint8Array,
): CommandResult {
return run({ program: 'git', args, env: { ...commandEnv(identity), ...env }, stdin });
}
export function collectBrainDoctorReport(
input: {
readonly registrySource: string;
readonly targetGitUrl: string;
readonly brainNamespace: string;
readonly identity: string;
readonly root: string;
},
run: CommandRunner,
): DoctorRuntimeReport {
const target = deriveBrainTarget(input.registrySource, input.targetGitUrl, input.brainNamespace);
const validation = run({
program: 'mosaic',
args: [
'cred',
'validate',
input.identity,
'--estate',
target.estate,
'--host',
target.host,
'--repo',
target.repo,
'--require',
'write',
'--json',
],
env: commandEnv(input.identity),
});
let access = assessCredentialResult(validation.stdout, {
identity: input.identity,
estate: target.estate,
host: target.host,
repo: target.repo,
});
if (validation.status !== access.exitCode) access = integrationFailure();
const rootExists = existsSync(input.root);
let gitRepository = false;
let remote: string | null = null;
let branch: string | null = null;
let worktreeState: BrainDoctorObservation['worktreeState'] = 'unmeasurable';
if (rootExists) {
const repository = runGit(run, input.identity, [
'-C',
input.root,
'rev-parse',
'--is-inside-work-tree',
]);
gitRepository = repository.status === 0 && repository.stdout.trim() === 'true';
if (gitRepository) {
const remoteResult = runGit(run, input.identity, [
'-C',
input.root,
'remote',
'get-url',
'origin',
]);
const branchResult = runGit(run, input.identity, [
'-C',
input.root,
'branch',
'--show-current',
]);
const statusResult = runGit(run, input.identity, ['-C', input.root, 'status', '--porcelain']);
if (remoteResult.status === 0) remote = remoteResult.stdout.trim();
if (branchResult.status === 0) branch = branchResult.stdout.trim();
if (statusResult.status === 0) {
worktreeState = statusResult.stdout.trim().length > 0 ? 'dirty' : 'clean';
}
}
}
const observation: BrainDoctorObservation = {
rootExists,
rootPrivate: rootExists && brainRootIsPrivate(input.root),
gitRepository,
remote,
branch,
worktreeState,
access,
};
const refusalMarker = `refused reason=${access.reasonCode}`;
const refusalObserved =
validation.status === 10 &&
access.outcome === 'refused' &&
access.reasonCode === 'no-token-for-identity' &&
validation.stderr.includes(refusalMarker);
return {
findings: evaluateBrainDoctor(observation, target.cloneUrl),
access,
refusalControl: {
observed: refusalObserved,
reasonCode: refusalObserved ? access.reasonCode : null,
},
};
}
export function collectBrainRefusalControl(
input: {
readonly registrySource: string;
readonly targetGitUrl: string;
readonly brainNamespace: string;
readonly refusalIdentity: string;
},
run: CommandRunner,
): BrainRefusalControlResult {
const target = deriveBrainTarget(input.registrySource, input.targetGitUrl, input.brainNamespace);
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(input.refusalIdentity)) {
return {
observed: false,
reasonCode: 'permission-evidence-disagrees',
gitReasonCode: 'invalid-control-identity',
apiReasonCode: 'invalid-control-identity',
};
}
const apiResult = run({
program: 'mosaic',
args: [
'cred',
'validate',
input.refusalIdentity,
'--estate',
target.estate,
'--host',
target.host,
'--repo',
target.repo,
'--require',
'write',
'--json',
],
env: commandEnv(input.refusalIdentity),
});
let api = assessCredentialResult(apiResult.stdout, {
identity: input.refusalIdentity,
estate: target.estate,
host: target.host,
repo: target.repo,
});
if (apiResult.status !== api.exitCode) api = integrationFailure();
const gitResult = runGit(run, input.refusalIdentity, ['ls-remote', target.cloneUrl, 'HEAD']);
const marker = /(?:^|\s)reason=([a-z0-9-]+)(?:\s|$)/.exec(gitResult.stderr)?.[1];
const stableRefusals = new Set([
'identity-required',
'estate-required',
'estate-host-mismatch',
'cross-estate-resolution',
'no-token-for-identity',
'tea-login-missing',
'tea-login-host-mismatch',
'provider-identity-mismatch',
'credential-rejected',
'permission-denied',
'organization-membership-required',
'team-membership-required',
]);
const gitReasonCode =
gitResult.status === 0
? 'transport-accepted'
: marker !== undefined && stableRefusals.has(marker) && gitResult.stdout.length === 0
? marker
: 'transport-indeterminate';
const observed =
api.outcome === 'refused' &&
gitReasonCode !== 'transport-accepted' &&
gitReasonCode !== 'transport-indeterminate' &&
gitReasonCode === api.reasonCode;
return {
observed,
reasonCode: observed ? api.reasonCode : 'permission-evidence-disagrees',
gitReasonCode,
apiReasonCode: api.reasonCode,
};
}
export function repairBrainDoctor(
input: {
readonly registrySource: string;
readonly targetGitUrl: string;
readonly brainNamespace: string;
readonly identity: string;
readonly root: string;
},
run: CommandRunner,
): DoctorRuntimeReport {
const target = deriveBrainTarget(input.registrySource, input.targetGitUrl, input.brainNamespace);
let report = collectBrainDoctorReport(input, run);
const actions = planBrainDoctorFix({
findings: report.findings,
target,
identity: input.identity,
root: input.root,
});
for (const action of actions) {
if (action.program === 'mosaic') {
run({ program: 'mosaic', args: action.args, env: commandEnv(input.identity) });
report = collectBrainDoctorReport(input, run);
if (report.access.outcome !== 'ok') return report;
continue;
}
if (action.findingCode === 'brain-clone-missing') {
try {
ensureBrainRootPrivate(input.root);
} catch {
return collectBrainDoctorReport(input, run);
}
}
const result = runGit(run, input.identity, action.args);
if (result.status !== 0) return collectBrainDoctorReport(input, run);
}
return collectBrainDoctorReport(input, run);
}
function requireSuccess(result: CommandResult, operation: string): void {
if (result.status !== 0) throw new Error(`${operation}-failed`);
}
function containedRelative(root: string, path: string): string {
if (isAbsolute(path) === false) throw new Error('brain-publish-path-must-be-absolute');
const absoluteRoot = resolve(root);
const absolutePath = resolve(path);
if (absolutePath === absoluteRoot || !absolutePath.startsWith(`${absoluteRoot}${sep}`)) {
throw new Error('brain-publish-path-escaped-root');
}
return relative(absoluteRoot, absolutePath).split(sep).join('/');
}
export function publishBrainPaths(
input: {
readonly root: string;
readonly identity: string;
readonly entries: readonly {
readonly path: string;
readonly content: Uint8Array;
}[];
readonly message: string;
},
run: CommandRunner,
): PublishEvidence {
if (input.entries.length === 0) throw new Error('brain-publish-paths-empty');
if (input.message.trim().length === 0) throw new Error('brain-publish-message-empty');
const entries = input.entries.map((entry) => {
if (entry.content.byteLength > MAX_PUBLISH_ENTRY_BYTES) {
throw new Error('brain-publish-entry-too-large');
}
return {
path: containedRelative(input.root, entry.path),
content: Uint8Array.from(entry.content),
};
});
const paths = entries.map((entry): string => entry.path);
if (new Set(paths).size !== paths.length) throw new Error('brain-publish-path-duplicate');
const readHead = (): string => {
const result = runGit(run, input.identity, ['-C', input.root, 'rev-parse', 'HEAD']);
requireSuccess(result, 'brain-git-read-commit');
const value = result.stdout.trim();
if (!COMMIT.test(value)) throw new Error('brain-git-commit-shape-invalid');
return value;
};
const verifyCommitIdentity = (commit: string): void => {
const result = runGit(run, input.identity, [
'-C',
input.root,
'show',
'-s',
'--format=%an%x00%ae%x00%cn%x00%ce',
commit,
]);
requireSuccess(result, 'brain-git-read-commit-identity');
const expectedEmail = `${input.identity}@fleet.mosaicstack.dev`;
const [author, authorEmail, committer, committerEmail] = result.stdout.trimEnd().split('\0');
if (
author !== input.identity ||
authorEmail !== expectedEmail ||
committer !== input.identity ||
committerEmail !== expectedEmail
) {
throw new Error('brain-git-commit-identity-mismatch');
}
};
const expectedObjects = new Map<string, string>();
const verifyCommitObjects = (commit: string): void => {
for (const [path, expected] of expectedObjects) {
const result = runGit(run, input.identity, [
'-C',
input.root,
'rev-parse',
`${commit}:${path}`,
]);
requireSuccess(result, 'brain-git-read-commit-object');
if (result.stdout.trim() !== expected) throw new Error('brain-git-commit-content-mismatch');
}
};
const reconcileRealIndex = (): void => {
for (const [path, objectId] of expectedObjects) {
requireSuccess(
runGit(run, input.identity, [
'-C',
input.root,
'update-index',
'--add',
'--cacheinfo',
'100644',
objectId,
path,
]),
'brain-git-reconcile-checkout-index',
);
}
};
const verifyCommitPaths = (commit: string): void => {
const changed = runGit(run, input.identity, [
'-C',
input.root,
'diff-tree',
'--root',
'--no-commit-id',
'--name-only',
'-r',
'-z',
commit,
]);
requireSuccess(changed, 'brain-git-read-commit-paths');
const names = changed.stdout.split('\0').filter(Boolean);
const approved = new Set(paths);
if (names.length === 0 || names.some((name: string): boolean => !approved.has(name))) {
throw new Error('brain-git-commit-paths-unapproved');
}
};
const base = readHead();
const indexRoot = mkdtempSync(join(tmpdir(), 'mosaic-brain-index-'));
const isolatedEnv = { GIT_INDEX_FILE: join(indexRoot, 'index') };
let commit = base;
let createdCommit = false;
try {
requireSuccess(
runGitWithEnv(run, input.identity, ['-C', input.root, 'read-tree', base], isolatedEnv),
'brain-git-isolated-index-init',
);
for (const entry of entries) {
const object = runGitWithEnv(
run,
input.identity,
['-C', input.root, 'hash-object', '-w', '--stdin'],
isolatedEnv,
entry.content,
);
requireSuccess(object, 'brain-git-write-approved-object');
const objectId = object.stdout.trim();
if (!GIT_OBJECT.test(objectId)) throw new Error('brain-git-object-shape-invalid');
expectedObjects.set(entry.path, objectId);
requireSuccess(
runGitWithEnv(
run,
input.identity,
[
'-C',
input.root,
'update-index',
'--add',
'--cacheinfo',
'100644',
objectId,
entry.path,
],
isolatedEnv,
),
'brain-git-stage-approved-object',
);
}
const difference = runGitWithEnv(
run,
input.identity,
['-C', input.root, 'diff', '--cached', '--quiet', '--exit-code', base, '--', ...paths],
isolatedEnv,
);
if (difference.status === 1) {
requireSuccess(
runGitWithEnv(
run,
input.identity,
[
'-C',
input.root,
'-c',
`user.name=${input.identity}`,
'-c',
`user.email=${input.identity}@fleet.mosaicstack.dev`,
'commit',
'-m',
input.message,
],
isolatedEnv,
),
'brain-git-commit',
);
commit = readHead();
verifyCommitPaths(commit);
verifyCommitObjects(commit);
verifyCommitIdentity(commit);
reconcileRealIndex();
createdCommit = true;
} else if (difference.status !== 0) {
throw new Error('brain-git-isolated-diff-failed');
}
} finally {
rmSync(indexRoot, { recursive: true, force: true });
}
let pushed = !createdCommit;
for (let attempt = 0; createdCommit && attempt < 3; attempt += 1) {
const push = runGit(run, input.identity, ['-C', input.root, 'push', 'origin', 'HEAD:main']);
if (push.status === 0) {
pushed = true;
break;
}
const concurrentUpdate = /non-fast-forward|fetch first|\[rejected\]/i.test(push.stderr);
if (!concurrentUpdate || attempt === 2) throw new Error('brain-git-push-failed');
requireSuccess(
runGit(run, input.identity, ['-C', input.root, 'fetch', 'origin', 'main']),
'brain-git-fetch-concurrent',
);
requireSuccess(
runGit(run, input.identity, [
'-C',
input.root,
'-c',
`user.name=${input.identity}`,
'-c',
`user.email=${input.identity}@fleet.mosaicstack.dev`,
'rebase',
'origin/main',
]),
'brain-git-rebase-concurrent',
);
commit = readHead();
verifyCommitPaths(commit);
verifyCommitObjects(commit);
verifyCommitIdentity(commit);
}
if (!pushed) throw new Error('brain-git-push-failed');
requireSuccess(
runGit(run, input.identity, ['-C', input.root, 'fetch', 'origin', 'main']),
'brain-git-fetch-readback',
);
const reachableResult = runGit(run, input.identity, [
'-C',
input.root,
'merge-base',
'--is-ancestor',
commit,
'origin/main',
]);
if (reachableResult.status !== 0 && reachableResult.status !== 1) {
throw new Error('brain-git-reachability-check-failed');
}
const remoteResult = runGit(run, input.identity, ['-C', input.root, 'rev-parse', 'origin/main']);
requireSuccess(remoteResult, 'brain-git-read-remote-head');
const remoteHead = remoteResult.stdout.trim();
if (!COMMIT.test(remoteHead)) throw new Error('brain-git-remote-head-shape-invalid');
return { commit, remoteHead, reachable: reachableResult.status === 0 };
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+27 -4
View File
@@ -29,6 +29,11 @@ import { readPersonaContractBlock } from '../fleet/persona-contract.js';
import { canonicalizeRoleClass } from './fleet-personas.js'; import { canonicalizeRoleClass } from './fleet-personas.js';
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js'; import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js'; import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
import {
defaultInstalledBrainDoctorOptions,
runInstalledBrainDoctorCheck,
} from './brain-doctor-check.js';
import { systemCommandRunner } from './brain-store-runtime.js';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024; const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
@@ -1257,8 +1262,11 @@ export function registerLaunchCommands(program: Command): void {
}); });
} }
// `doctor` — the framework drift audit (bash script) PLUS the #869 // `doctor` — the framework drift audit (bash script), the #869
// Point-1 C5 lease-enforcement activation check (TS, reusing C1's // Point-1 C5 lease-enforcement activation check, and the #1051 per-estate
// durable brain check. Both TS checks run before the bash audit and can
// force a non-zero result for hard/indeterminate failures.
// The lease check reuses C1's
// `leaseEnforcementActivatable()` and C3's `checkBrokerSupervisorHealth()`). // `leaseEnforcementActivatable()` and C3's `checkBrokerSupervisorHealth()`).
// Kept out of the generic `directCommands` loop above because this check // Kept out of the generic `directCommands` loop above because this check
// must run and report BEFORE the bash script's own exit, and must be able // must run and report BEFORE the bash script's own exit, and must be able
@@ -1267,14 +1275,29 @@ export function registerLaunchCommands(program: Command): void {
// undiagnosed (see lease-doctor-check.ts docstring). // undiagnosed (see lease-doctor-check.ts docstring).
program program
.command('doctor') .command('doctor')
.description('Health audit — detect drift, missing files, and #869 lease-activation gaps') .description('Health audit — detect drift, lease gaps, and per-estate brain defects')
.allowUnknownOption(true) .allowUnknownOption(true)
.allowExcessArguments(true) .allowExcessArguments(true)
.action(async (_opts: unknown, cmd: Command) => { .action(async (_opts: unknown, cmd: Command) => {
checkMosaicHome(); checkMosaicHome();
const leaseCheck = await runLeaseEnforcementDoctorCheck(); const leaseCheck = await runLeaseEnforcementDoctorCheck();
const leaseCheckFailed = printLeaseDoctorCheck(leaseCheck); const leaseCheckFailed = printLeaseDoctorCheck(leaseCheck);
runDoctorScriptAndExit(fwScript('mosaic-doctor'), cmd.args, leaseCheckFailed); const fix = cmd.args.includes('--fix');
const brainCheck = runInstalledBrainDoctorCheck(
defaultInstalledBrainDoctorOptions(fix),
systemCommandRunner,
);
for (const line of brainCheck.lines) {
(brainCheck.status === 'ok' ? console.log : console.error)(line);
}
const brainCheckFailed =
brainCheck.status === 'error' ||
(brainCheck.status === 'warn' && cmd.args.includes('--fail-on-warn'));
runDoctorScriptAndExit(
fwScript('mosaic-doctor'),
cmd.args,
leaseCheckFailed || brainCheckFailed,
);
}); });
} }
@@ -0,0 +1,52 @@
import type {
ProviderIdentityEvidenceDto,
ReceivePackEvidenceDto,
RepositoryPermission,
RepositoryPermissionEvidenceDto,
} from './credential-result.dto.js';
export interface ResolvedCredential {
readonly identity: string;
readonly estate: string;
readonly host: string;
readonly resolutionId: string;
readonly secret: Uint8Array;
}
export interface CredentialResolver {
resolve(identity: string, estate: string, host: string): Promise<ResolvedCredential | undefined>;
}
export interface GiteaCredentialProvider {
readIdentity(resolved: ResolvedCredential): Promise<ProviderIdentityEvidenceDto>;
readRepositoryPermission(
resolved: ResolvedCredential,
repo: string,
): Promise<RepositoryPermissionEvidenceDto>;
probeReceivePack(
resolved: ResolvedCredential | undefined,
repo: string,
): Promise<ReceivePackEvidenceDto>;
}
export interface CredentialEstateRegistry {
matches(estate: string, host: string): boolean;
}
export interface CredentialValidationDependencies {
readonly resolver: CredentialResolver;
readonly provider: GiteaCredentialProvider;
readonly estateRegistry: CredentialEstateRegistry;
}
export interface GiteaReadValidationRequestDto {
readonly identity: string;
readonly estate: string;
readonly host: string;
readonly repo: string;
readonly requiredPermission?: RepositoryPermission;
}
export interface GiteaWriteValidationRequestDto extends GiteaReadValidationRequestDto {
readonly readOnlyControlIdentity: string;
}
@@ -0,0 +1,84 @@
export type CredentialOutcome = 'ok' | 'refused' | 'error' | 'indeterminate';
export type CredentialMutationState = 'none' | 'not-started' | 'applied' | 'unknown';
export type RepositoryPermission = 'none' | 'read' | 'write' | 'admin';
export type ReceivePackState = 'advertised' | 'refused';
export interface CredentialReasonDto {
readonly code: string;
readonly message: string;
}
export interface CredentialSubjectDto {
readonly identity: string;
readonly estate: string;
readonly host: string;
readonly repo: string;
}
export interface ProviderIdentityEvidenceDto {
readonly login: string;
readonly endpoint: string;
readonly contentType: string;
}
export interface RepositoryPermissionEvidenceDto {
readonly effective: RepositoryPermission;
readonly endpoint: string;
readonly contentType: string;
}
export interface ReceivePackEvidenceDto {
readonly state: ReceivePackState;
readonly principal: string | null;
readonly resolutionId: string | null;
readonly contentType: string;
}
export interface ReadOnlyControlEvidenceDto {
readonly identity: string;
readonly providerPermission: RepositoryPermission;
readonly receivePack: ReceivePackState;
}
export interface WriteDifferentialEvidenceDto {
readonly state: 'can-write';
readonly credentialBinding: 'same-resolution';
readonly transportPrincipal: string;
readonly authenticatedReceivePack: 'advertised';
readonly readOnlyControl: ReadOnlyControlEvidenceDto;
readonly unauthenticatedReceivePack: 'refused';
readonly artifactCreated: false;
readonly proves: string;
readonly doesNotProve: string;
}
export interface TokenCapabilitiesEvidenceDto {
readonly state: 'measured' | 'not-measured';
readonly scopes: readonly string[];
readonly source: 'provider-token-object' | 'runtime-not-authorized';
}
export interface CredentialValidationEvidenceDto {
readonly providerIdentity: ProviderIdentityEvidenceDto | null;
readonly tokenCapabilities: TokenCapabilitiesEvidenceDto;
readonly repositoryPermission: RepositoryPermissionEvidenceDto | null;
readonly writeDifferential: WriteDifferentialEvidenceDto | null;
}
export interface CredentialAuditResultDto {
readonly journalId: string | null;
readonly state: 'not-started' | 'open' | 'sealed';
}
export interface CredentialValidationResultDto {
readonly schemaVersion: 1;
readonly operation: 'validate' | 'whoami';
readonly outcome: CredentialOutcome;
readonly exitCode: 0 | 10 | 20 | 30;
readonly retryable: boolean;
readonly subject: CredentialSubjectDto;
readonly mutation: CredentialMutationState;
readonly reason: CredentialReasonDto;
readonly evidence: CredentialValidationEvidenceDto;
readonly audit: CredentialAuditResultDto;
}
@@ -0,0 +1,15 @@
export type CredentialProviderKind = 'gitea';
export interface CredentialHostConfigDto {
readonly host: string;
readonly provider: CredentialProviderKind;
readonly apiBaseUrl: string;
readonly tokenPrefix: string;
}
export interface CredentialEstateConfigDto {
readonly name: string;
readonly readOnlyControlIdentity?: string;
readonly inventoryAuthorityIdentity?: string;
readonly hosts: readonly CredentialHostConfigDto[];
}
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import { parseCredentialEstateRegistry } from './estate-registry.js';
const validRegistry = JSON.stringify({
version: 1,
estates: [
{
name: 'homelab',
readOnlyControlIdentity: 'read-control',
hosts: [
{
host: 'git.example.invalid',
provider: 'gitea',
apiBaseUrl: 'https://git.example.invalid',
tokenPrefix: 'gitea-example',
},
],
},
],
});
describe('credential estate registry', (): void => {
it('requires an exact declared estate-host pair', (): void => {
const registry = parseCredentialEstateRegistry(validRegistry);
expect(registry.matches('homelab', 'git.example.invalid')).toBe(true);
expect(registry.matches('usc', 'git.example.invalid')).toBe(false);
expect(registry.matches('homelab', 'other.example.invalid')).toBe(false);
expect(registry.resolveByHost('git.example.invalid')).toMatchObject({
estate: 'homelab',
host: { host: 'git.example.invalid', provider: 'gitea' },
});
expect(registry.resolveByHost('other.example.invalid')).toBeUndefined();
});
it('rejects a provider URL whose host differs from the declared host', (): void => {
const source = validRegistry.replace(
'https://git.example.invalid',
'https://other.example.invalid',
);
expect(() => parseCredentialEstateRegistry(source)).toThrow(/api-host-mismatch/);
});
it('rejects one host assigned to multiple estates', (): void => {
const source = JSON.stringify({
version: 1,
estates: [
{
name: 'homelab',
hosts: [
{
host: 'git.example.invalid',
provider: 'gitea',
apiBaseUrl: 'https://git.example.invalid',
tokenPrefix: 'gitea-example',
},
],
},
{
name: 'other',
hosts: [
{
host: 'git.example.invalid',
provider: 'gitea',
apiBaseUrl: 'https://git.example.invalid',
tokenPrefix: 'gitea-other',
},
],
},
],
});
expect(() => parseCredentialEstateRegistry(source)).toThrow(/duplicate-host/);
});
it('rejects URLs with userinfo, path, query, fragment, trailing slash, or non-HTTPS scheme', (): void => {
for (const apiBaseUrl of [
'http://git.example.invalid',
'https://[email protected]',
'https://git.example.invalid/',
'https://git.example.invalid/api',
'https://git.example.invalid?x=1',
'https://git.example.invalid#x',
]) {
const source = validRegistry.replace('https://git.example.invalid', apiBaseUrl);
expect(() => parseCredentialEstateRegistry(source), apiBaseUrl).toThrow(/invalid-api-url/);
}
});
it('requires a configured read-only control for write validation', (): void => {
const registry = parseCredentialEstateRegistry(validRegistry);
const withoutControl = parseCredentialEstateRegistry(
validRegistry.replace('"readOnlyControlIdentity":"read-control",', ''),
);
expect(registry.readOnlyControl('homelab')).toBe('read-control');
expect(() => withoutControl.readOnlyControl('homelab')).toThrow(/read-only-control-missing/);
});
});
@@ -0,0 +1,167 @@
import { z } from 'zod';
import type { CredentialEstateRegistry } from './credential-provider.dto.js';
import type { CredentialEstateConfigDto, CredentialHostConfigDto } from './estate-registry.dto.js';
const NAME = /^[a-z0-9][a-z0-9-]*$/;
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
const HOST = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
const hostSchema = z
.object({
host: z.string().regex(HOST),
provider: z.literal('gitea'),
apiBaseUrl: z.string(),
tokenPrefix: z.string().regex(NAME),
})
.strict();
const estateSchema = z
.object({
name: z.string().regex(NAME),
readOnlyControlIdentity: z.string().regex(IDENTITY).optional(),
inventoryAuthorityIdentity: z.string().regex(IDENTITY).optional(),
hosts: z.array(hostSchema).min(1),
})
.strict();
const registrySchema = z
.object({
version: z.literal(1),
estates: z.array(estateSchema).min(1),
})
.strict();
export class CredentialEstateRegistryError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(`Credential estate registry rejected: code=${code} ${message}`);
this.name = 'CredentialEstateRegistryError';
}
}
function validateApiUrl(host: CredentialHostConfigDto): void {
let url: URL;
try {
url = new URL(host.apiBaseUrl);
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new CredentialEstateRegistryError('invalid-api-url', detail);
}
if (
url.protocol !== 'https:' ||
url.username !== '' ||
url.password !== '' ||
url.pathname !== '/' ||
host.apiBaseUrl !== url.origin ||
url.search !== '' ||
url.hash !== ''
) {
throw new CredentialEstateRegistryError(
'invalid-api-url',
'provider API URL must be an HTTPS origin without userinfo, path, query, or fragment',
);
}
if (url.hostname !== host.host) {
throw new CredentialEstateRegistryError(
'api-host-mismatch',
'provider API URL hostname does not equal the declared host',
);
}
}
export class ParsedCredentialEstateRegistry implements CredentialEstateRegistry {
private readonly estates: ReadonlyMap<string, CredentialEstateConfigDto>;
constructor(estates: readonly CredentialEstateConfigDto[]) {
this.estates = new Map(
estates.map(
(estate: CredentialEstateConfigDto): readonly [string, CredentialEstateConfigDto] => [
estate.name,
estate,
],
),
);
}
matches(estate: string, host: string): boolean {
return this.resolve(estate, host) !== undefined;
}
resolve(estate: string, host: string): CredentialHostConfigDto | undefined {
return this.estates
.get(estate)
?.hosts.find((candidate: CredentialHostConfigDto): boolean => candidate.host === host);
}
resolveByHost(
host: string,
): { readonly estate: string; readonly host: CredentialHostConfigDto } | undefined {
for (const [estate, config] of this.estates) {
const match = config.hosts.find(
(candidate: CredentialHostConfigDto): boolean => candidate.host === host,
);
if (match !== undefined) return { estate, host: match };
}
return undefined;
}
inventoryAuthority(estate: string): string {
const identity = this.estates.get(estate)?.inventoryAuthorityIdentity;
if (identity === undefined) {
throw new CredentialEstateRegistryError(
'inventory-authority-missing',
`estate ${estate} has no delegated inventory authority identity`,
);
}
return identity;
}
readOnlyControl(estate: string): string {
const identity = this.estates.get(estate)?.readOnlyControlIdentity;
if (identity === undefined) {
throw new CredentialEstateRegistryError(
'read-only-control-missing',
`estate ${estate} has no provider-confirmed read-only control identity`,
);
}
return identity;
}
}
export function parseCredentialEstateRegistry(source: string): ParsedCredentialEstateRegistry {
let raw: unknown;
try {
raw = JSON.parse(source);
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new CredentialEstateRegistryError('invalid-json', detail);
}
const parsed = registrySchema.safeParse(raw);
if (!parsed.success) {
throw new CredentialEstateRegistryError(
'invalid-schema',
parsed.error.issues[0]?.message ?? 'invalid',
);
}
const estateNames = new Set<string>();
const hostNames = new Set<string>();
for (const estate of parsed.data.estates) {
if (estateNames.has(estate.name)) {
throw new CredentialEstateRegistryError('duplicate-estate', estate.name);
}
estateNames.add(estate.name);
for (const host of estate.hosts) {
validateApiUrl(host);
if (hostNames.has(host.host)) {
throw new CredentialEstateRegistryError('duplicate-host', host.host);
}
hostNames.add(host.host);
}
}
return new ParsedCredentialEstateRegistry(parsed.data.estates);
}
+4
View File
@@ -22,6 +22,8 @@ export interface SecureFileSnapshot {
mode: number; mode: number;
dev: number | bigint; dev: number | bigint;
ino: number | bigint; ino: number | bigint;
uid: number;
gid: number;
} }
function sameIdentity( function sameIdentity(
@@ -235,6 +237,8 @@ export function readRegularFileSecure(
mode: Number(opened.mode), mode: Number(opened.mode),
dev: opened.dev, dev: opened.dev,
ino: opened.ino, ino: opened.ino,
uid: opened.uid,
gid: opened.gid,
}; };
} finally { } finally {
closeDescriptors(openedFile.descriptors); closeDescriptors(openedFile.descriptors);
+1 -1
View File
@@ -37,7 +37,7 @@ const RUNTIME_DEFS: Record<
label: 'Pi', label: 'Pi',
command: 'pi', command: 'pi',
versionFlag: '--version', versionFlag: '--version',
installHint: 'curl -fsSL https://pi.dev/install.sh | sh', installHint: 'npm install -g @mariozechner/pi-coding-agent',
}, },
}; };
@@ -85,16 +85,16 @@ function makeConfigService(): ConfigService {
describe('finalizeStage — skill installer', () => { describe('finalizeStage — skill installer', () => {
let tmp: string; let tmp: string;
let scriptsDir: string; let binDir: string;
let syncScript: string; let syncScript: string;
beforeEach(() => { beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'mosaic-finalize-')); tmp = mkdtempSync(join(tmpdir(), 'mosaic-finalize-'));
scriptsDir = join(tmp, 'tools', '_scripts'); binDir = join(tmp, 'bin');
mkdirSync(scriptsDir, { recursive: true }); mkdirSync(binDir, { recursive: true });
syncScript = join(scriptsDir, 'mosaic-sync-skills'); syncScript = join(binDir, 'mosaic-sync-skills');
// Default: current framework layout has tools/_scripts and succeeds. // Default: script exists and succeeds
writeFileSync(syncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 }); writeFileSync(syncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 });
spawnSyncMock.mockReturnValue({ status: 0, stdout: 'ok', stderr: '' }); spawnSyncMock.mockReturnValue({ status: 0, stdout: 'ok', stderr: '' });
}); });
@@ -156,29 +156,10 @@ describe('finalizeStage — skill installer', () => {
const call = findSkillsSyncCall(); const call = findSkillsSyncCall();
expect(call).toBeDefined(); expect(call).toBeDefined();
expect(call![1]).toEqual([join(tmp, 'tools', '_scripts', 'mosaic-sync-skills')]);
const opts = call![2] as { env?: Record<string, string> }; const opts = call![2] as { env?: Record<string, string> };
expect(opts.env?.['MOSAIC_INSTALL_SKILLS']).toBe('brainstorming:lint:systematic-debugging'); expect(opts.env?.['MOSAIC_INSTALL_SKILLS']).toBe('brainstorming:lint:systematic-debugging');
}); });
it('falls back to legacy bin path for pre-migration installs', async () => {
rmSync(syncScript);
const legacyBinDir = join(tmp, 'bin');
mkdirSync(legacyBinDir, { recursive: true });
const legacySyncScript = join(legacyBinDir, 'mosaic-sync-skills');
writeFileSync(legacySyncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 });
const state = makeState(tmp, ['brainstorming']);
const p = buildPrompter();
const config = makeConfigService();
await finalizeStage(p, state, config);
const call = findSkillsSyncCall();
expect(call).toBeDefined();
expect(call![1]).toEqual([legacySyncScript]);
});
it('skips the sync script entirely when no skills are selected', async () => { it('skips the sync script entirely when no skills are selected', async () => {
const state = makeState(tmp, []); const state = makeState(tmp, []);
const p = buildPrompter(); const p = buildPrompter();
@@ -218,9 +199,7 @@ describe('finalizeStage — skill installer', () => {
// spawnSync should NOT have been called for the skills script // spawnSync should NOT have been called for the skills script
expect(findSkillsSyncCall()).toBeUndefined(); expect(findSkillsSyncCall()).toBeUndefined();
expect(p.warn).toHaveBeenCalledWith( expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('not found'));
expect.stringContaining('tools/_scripts/mosaic-sync-skills'),
);
}); });
it('includes skills count in the summary when install succeeds', async () => { it('includes skills count in the summary when install succeeds', async () => {
+14 -45
View File
@@ -13,22 +13,16 @@ import {
type SkillSyncResult as ClaudeSkillSyncResult, type SkillSyncResult as ClaudeSkillSyncResult,
} from '../commands/skill.js'; } from '../commands/skill.js';
function frameworkScriptPath(mosaicHome: string, name: string): string { /**
const currentPath = join(mosaicHome, 'tools', '_scripts', name); * Link runtime assets. Returns a warning string when the install-ordering
if (existsSync(currentPath)) return currentPath; * guard (#869 Point-1 C2) reported a degraded outcome i.e. the
* lease-enforcement hooks were NOT wired into ~/.claude/settings.json because
// Backward-compatible fallback for pre-migration installs that still have bin/. * this host could not confirm it can activate them so the caller can
const legacyPath = join(mosaicHome, 'bin', name); * surface it via `p.warn(...)` instead of it being swallowed by `stdio:
if (existsSync(legacyPath)) return legacyPath; * 'pipe'`. Non-fatal either way: the wizard always continues.
*/
// Return the current expected path so user-facing errors point at the layout
// installed by packages/mosaic/framework/install.sh.
return currentPath;
}
/** Link runtime assets and surface a non-zero install-ordering guard outcome. */
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): string | undefined { function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): string | undefined {
const script = frameworkScriptPath(mosaicHome, 'mosaic-link-runtime-assets'); const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
if (!existsSync(script)) return undefined; if (!existsSync(script)) return undefined;
try { try {
const result = spawnSync('bash', [script], { const result = spawnSync('bash', [script], {
@@ -75,7 +69,7 @@ function syncSkills(mosaicHome: string, selectedSkills: string[]): SyncSkillsRes
return { success: true, installedCount: 0 }; return { success: true, installedCount: 0 };
} }
const script = frameworkScriptPath(mosaicHome, 'mosaic-sync-skills'); const script = join(mosaicHome, 'bin', 'mosaic-sync-skills');
if (!existsSync(script)) { if (!existsSync(script)) {
return { return {
success: false, success: false,
@@ -123,7 +117,7 @@ interface DoctorResult {
} }
function runDoctor(mosaicHome: string): DoctorResult { function runDoctor(mosaicHome: string): DoctorResult {
const script = frameworkScriptPath(mosaicHome, 'mosaic-doctor'); const script = join(mosaicHome, 'bin', 'mosaic-doctor');
if (!existsSync(script)) { if (!existsSync(script)) {
return { warnings: 0, output: 'mosaic-doctor not found' }; return { warnings: 0, output: 'mosaic-doctor not found' };
} }
@@ -176,24 +170,11 @@ function setupPath(mosaicHome: string, _p: WizardPrompter): PathAction {
} }
} }
export interface FinalizeStageOptions {
/**
* Defer the success summary/outro so callers can run downstream readiness
* gates (gateway health/bootstrap) before claiming Mosaic is ready.
*/
deferSummary?: boolean;
}
export interface FinalizeStageResult {
showSummary: () => void;
}
export async function finalizeStage( export async function finalizeStage(
p: WizardPrompter, p: WizardPrompter,
state: WizardState, state: WizardState,
config: ConfigService, config: ConfigService,
options: FinalizeStageOptions = {}, ): Promise<void> {
): Promise<FinalizeStageResult> {
p.separator(); p.separator();
const spin = p.spinner(); const spin = p.spinner();
@@ -288,12 +269,7 @@ export async function finalizeStage(
// 7. PATH setup // 7. PATH setup
const pathAction = setupPath(state.mosaicHome, p); const pathAction = setupPath(state.mosaicHome, p);
let summaryShown = false; // 8. Summary
const showSummary = () => {
if (summaryShown) return;
summaryShown = true;
// 7. Summary
const skillsSummary = skillsResult.success const skillsSummary = skillsResult.success
? skillsResult.installedCount > 0 ? skillsResult.installedCount > 0
? `${skillsResult.installedCount.toString()} installed` ? `${skillsResult.installedCount.toString()} installed`
@@ -318,7 +294,7 @@ export async function finalizeStage(
p.note(summary.join('\n'), 'Installation Summary'); p.note(summary.join('\n'), 'Installation Summary');
// 8. Next steps // 9. Next steps
const nextSteps: string[] = []; const nextSteps: string[] = [];
if (pathAction === 'added') { if (pathAction === 'added') {
const profilePath = getShellProfilePath(); const profilePath = getShellProfilePath();
@@ -333,11 +309,4 @@ export async function finalizeStage(
p.note(nextSteps.map((s, i) => `${(i + 1).toString()}. ${s}`).join('\n'), 'Next Steps'); p.note(nextSteps.map((s, i) => `${(i + 1).toString()}. ${s}`).join('\n'), 'Next Steps');
p.outro('Mosaic is ready.'); p.outro('Mosaic is ready.');
};
if (!options.deferSummary) {
showSummary();
}
return { showSummary };
} }
@@ -136,7 +136,6 @@ describe('gatewayConfigStage', () => {
delete process.env['MOSAIC_STORAGE_TIER']; delete process.env['MOSAIC_STORAGE_TIER'];
delete process.env['MOSAIC_DATABASE_URL']; delete process.env['MOSAIC_DATABASE_URL'];
delete process.env['MOSAIC_VALKEY_URL']; delete process.env['MOSAIC_VALKEY_URL'];
delete process.env['MOSAIC_GATEWAY_SKIP_NPM_INSTALL'];
}); });
afterEach(() => { afterEach(() => {
@@ -168,75 +167,6 @@ describe('gatewayConfigStage', () => {
expect(state.gateway?.regeneratedConfig).toBe(true); expect(state.gateway?.regeneratedConfig).toBe(true);
}); });
it('installs the gateway package on fresh install when skipInstall is not set', async () => {
const p = buildPrompter();
const state = makeState('/home/user/.config/mosaic');
const result = await gatewayConfigStage(p, state, {
host: 'localhost',
defaultPort: 14242,
skipInstall: false,
});
expect(result.ready).toBe(true);
expect(daemonState.installPkgCalled).toBe(1);
});
it('honors MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 and skips the registry install (dev/offline installs)', async () => {
process.env['MOSAIC_GATEWAY_SKIP_NPM_INSTALL'] = '1';
const p = buildPrompter();
const state = makeState('/home/user/.config/mosaic');
const result = await gatewayConfigStage(p, state, {
host: 'localhost',
defaultPort: 14242,
skipInstall: false,
});
// The source-built global gateway must NOT be overwritten by @latest.
expect(result.ready).toBe(true);
expect(daemonState.installPkgCalled).toBe(0);
});
it('does not ask for a gateway API key when provider setup was completed with no key', async () => {
delete process.env['MOSAIC_ASSUME_YES'];
const originalIsTTY = process.stdin.isTTY;
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
try {
const textFn = vi.fn(async (opts: { message: string; initialValue?: string }) => {
if (opts.message === 'Gateway port') return opts.initialValue ?? '14242';
if (opts.message === 'Web UI hostname (for browser access)') return 'localhost';
if (opts.message.includes('API_KEY')) {
throw new Error('gateway API key prompt should be skipped');
}
return '';
});
const p = buildPrompter({ text: textFn, select: vi.fn().mockResolvedValue('local') });
const state = makeState('/home/user/.config/mosaic');
const result = await gatewayConfigStage(p, state, {
host: 'localhost',
defaultPort: 14242,
skipInstall: true,
providerType: 'none',
});
expect(result.ready).toBe(true);
expect(textFn).not.toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('API_KEY') }),
);
const envContents = readFileSync(daemonState.envFile, 'utf-8');
expect(envContents).not.toContain('ANTHROPIC_API_KEY=');
expect(envContents).not.toContain('OPENAI_API_KEY=');
} finally {
Object.defineProperty(process.stdin, 'isTTY', {
value: originalIsTTY,
configurable: true,
});
}
});
it('short-circuits when gateway is already fully installed and user declines rerun', async () => { it('short-circuits when gateway is already fully installed and user declines rerun', async () => {
// Pre-populate both files + running daemon + meta with token // Pre-populate both files + running daemon + meta with token
const fs = require('node:fs'); const fs = require('node:fs');
+1 -9
View File
@@ -294,12 +294,7 @@ export async function gatewayConfigStage(
} }
// Install the gateway npm package on first install or after failure. // Install the gateway npm package on first install or after failure.
// MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 forces a skip even without opts.skipInstall: if (!opts.skipInstall && !daemonRunning) {
// used by dev/offline installs where @mosaicstack/gateway is already present
// globally (e.g. a build-from-source `install.sh --dev`) and must not be
// overwritten by the registry @latest build.
const skipNpmInstall = opts.skipInstall || process.env['MOSAIC_GATEWAY_SKIP_NPM_INSTALL'] === '1';
if (!skipNpmInstall && !daemonRunning) {
installGatewayPackage(); installGatewayPackage();
} }
@@ -511,9 +506,6 @@ async function collectAndWriteConfig(
if (opts.providerKey) { if (opts.providerKey) {
anthropicKey = opts.providerKey; anthropicKey = opts.providerKey;
p.log(`Using API key from provider setup (${opts.providerType ?? 'unknown'}).`); p.log(`Using API key from provider setup (${opts.providerType ?? 'unknown'}).`);
} else if (opts.providerType === 'none') {
anthropicKey = '';
p.log('No API key provided during provider setup; skipping gateway API key prompt.');
} else { } else {
anthropicKey = await p.text({ anthropicKey = await p.text({
message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)', message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)',
+5 -15
View File
@@ -37,7 +37,6 @@ export async function quickStartPath(
// 1. Provider setup (first question) // 1. Provider setup (first question)
await providerSetupStage(prompter, state); await providerSetupStage(prompter, state);
state.completedSections?.add('providers');
// Apply sensible defaults for everything else // Apply sensible defaults for everything else
state.soul.agentName ??= 'Mosaic'; state.soul.agentName ??= 'Mosaic';
@@ -58,13 +57,9 @@ export async function quickStartPath(
// Skills (recommended set, no user input in quick mode) // Skills (recommended set, no user input in quick mode)
await skillsSelectStage(prompter, state); await skillsSelectStage(prompter, state);
state.completedSections?.add('skills');
// Finalize writes configs/assets/skills, but defer the success summary until // Finalize (writes configs, links runtime assets, syncs skills)
// after the gateway health/bootstrap gates complete. await finalizeStage(prompter, state, configService);
const finalizeResult = await finalizeStage(prompter, state, configService, {
deferSummary: true,
});
// Gateway config + bootstrap // Gateway config + bootstrap
if (!options.skipGateway) { if (!options.skipGateway) {
@@ -77,7 +72,7 @@ export async function quickStartPath(
portOverride: options.gatewayPortOverride, portOverride: options.gatewayPortOverride,
skipInstall: options.skipGatewayNpmInstall, skipInstall: options.skipGatewayNpmInstall,
providerKey: state.providerKey, providerKey: state.providerKey,
providerType: state.providerType, providerType: state.providerType ?? 'none',
}); });
if (!configResult.ready || !configResult.host || !configResult.port) { if (!configResult.ready || !configResult.host || !configResult.port) {
@@ -85,9 +80,7 @@ export async function quickStartPath(
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.'); prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
process.exit(1); process.exit(1);
} }
return; } else {
}
const bootstrapResult = await gatewayBootstrapStage(prompter, state, { const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
host: configResult.host, host: configResult.host,
port: configResult.port, port: configResult.port,
@@ -95,14 +88,11 @@ export async function quickStartPath(
if (!bootstrapResult.completed) { if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.'); prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1); process.exit(1);
return;
} }
finalizeResult.showSummary(); }
} catch (err) { } catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`); prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err; throw err;
} }
} else {
finalizeResult.showSummary();
} }
} }
+13 -55
View File
@@ -126,11 +126,6 @@ type MenuChoice =
| 'advanced' | 'advanced'
| 'finish'; | 'finish';
function menuSectionKey(section: MenuChoice): MenuSection | null {
if (section === 'quick-start' || section === 'finish') return null;
return section === 'gateway-config' ? 'gateway' : section;
}
function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string { function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string {
const labels: Record<MenuChoice, string> = { const labels: Record<MenuChoice, string> = {
'quick-start': 'Quick Start', 'quick-start': 'Quick Start',
@@ -142,24 +137,14 @@ function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string {
finish: 'Finish & Apply', finish: 'Finish & Apply',
}; };
const base = labels[section]; const base = labels[section];
const sectionKey = menuSectionKey(section); const sectionKey: MenuSection =
if (sectionKey && completed.has(sectionKey)) { section === 'gateway-config' ? 'gateway' : (section as MenuSection);
if (completed.has(sectionKey)) {
return `${base} [done]`; return `${base} [done]`;
} }
return base; return base;
} }
function skipCompletedMenuChoice(
prompter: WizardPrompter,
completed: Set<MenuSection>,
choice: MenuChoice,
): boolean {
const sectionKey = menuSectionKey(choice);
if (!sectionKey || !completed.has(sectionKey)) return false;
prompter.log(`${menuLabel(choice, completed)} is already complete; skipping.`);
return true;
}
async function runMenuLoop( async function runMenuLoop(
prompter: WizardPrompter, prompter: WizardPrompter,
state: WizardState, state: WizardState,
@@ -216,25 +201,21 @@ async function runMenuLoop(
return; // Quick start is a complete flow — exit menu return; // Quick start is a complete flow — exit menu
case 'providers': case 'providers':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
await providerSetupStage(prompter, state); await providerSetupStage(prompter, state);
completed.add('providers'); completed.add('providers');
break; break;
case 'identity': case 'identity':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
await agentIntentStage(prompter, state); await agentIntentStage(prompter, state);
completed.add('identity'); completed.add('identity');
break; break;
case 'skills': case 'skills':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
await skillsSelectStage(prompter, state); await skillsSelectStage(prompter, state);
completed.add('skills'); completed.add('skills');
break; break;
case 'gateway-config': case 'gateway-config':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
// Gateway config is handled during Finish — mark as "configured" // Gateway config is handled during Finish — mark as "configured"
// after user reviews settings. // after user reviews settings.
await runGatewaySubMenu(prompter, state, options); await runGatewaySubMenu(prompter, state, options);
@@ -242,7 +223,6 @@ async function runMenuLoop(
break; break;
case 'advanced': case 'advanced':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
await runAdvancedSubMenu(prompter, state); await runAdvancedSubMenu(prompter, state);
completed.add('advanced'); completed.add('advanced');
break; break;
@@ -330,11 +310,8 @@ async function runFinishPath(
await skillsSelectStage(prompter, state); await skillsSelectStage(prompter, state);
} }
// Finalize writes configs/assets/skills, but defer the success summary until // Finalize (writes configs, links runtime assets, syncs skills)
// after the gateway health/bootstrap gates complete. await finalizeStage(prompter, state, configService);
const finalizeResult = await finalizeStage(prompter, state, configService, {
deferSummary: true,
});
// Gateway stages // Gateway stages
if (!options.skipGateway) { if (!options.skipGateway) {
@@ -345,7 +322,7 @@ async function runFinishPath(
portOverride: options.gatewayPortOverride, portOverride: options.gatewayPortOverride,
skipInstall: options.skipGatewayNpmInstall, skipInstall: options.skipGatewayNpmInstall,
providerKey: state.providerKey, providerKey: state.providerKey,
providerType: state.providerType, providerType: state.providerType ?? 'none',
}); });
if (configResult.ready && configResult.host && configResult.port) { if (configResult.ready && configResult.host && configResult.port) {
@@ -356,16 +333,12 @@ async function runFinishPath(
if (!bootstrapResult.completed) { if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.'); prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1); process.exit(1);
return;
} }
finalizeResult.showSummary();
} }
} catch (err) { } catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`); prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err; throw err;
} }
} else {
finalizeResult.showSummary();
} }
} }
@@ -401,11 +374,8 @@ async function runHeadlessPath(
// Skills // Skills
await skillsSelectStage(prompter, state); await skillsSelectStage(prompter, state);
// Finalize writes configs/assets/skills, but defer the success summary until // Finalize
// after the gateway health/bootstrap gates complete. await finalizeStage(prompter, state, configService);
const finalizeResult = await finalizeStage(prompter, state, configService, {
deferSummary: true,
});
// Gateway stages // Gateway stages
if (!options.skipGateway) { if (!options.skipGateway) {
@@ -416,15 +386,13 @@ async function runHeadlessPath(
portOverride: options.gatewayPortOverride, portOverride: options.gatewayPortOverride,
skipInstall: options.skipGatewayNpmInstall, skipInstall: options.skipGatewayNpmInstall,
providerKey: state.providerKey, providerKey: state.providerKey,
providerType: state.providerType, providerType: state.providerType ?? 'none',
}); });
if (!configResult.ready || !configResult.host || !configResult.port) { if (!configResult.ready || !configResult.host || !configResult.port) {
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.'); prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
process.exit(1); process.exit(1);
return; } else {
}
const bootstrapResult = await gatewayBootstrapStage(prompter, state, { const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
host: configResult.host, host: configResult.host,
port: configResult.port, port: configResult.port,
@@ -432,15 +400,12 @@ async function runHeadlessPath(
if (!bootstrapResult.completed) { if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.'); prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1); process.exit(1);
return;
} }
finalizeResult.showSummary(); }
} catch (err) { } catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`); prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err; throw err;
} }
} else {
finalizeResult.showSummary();
} }
} }
@@ -461,11 +426,8 @@ async function runKeepPath(
// Skills // Skills
await skillsSelectStage(prompter, state); await skillsSelectStage(prompter, state);
// Finalize writes configs/assets/skills, but defer the success summary until // Finalize
// after the gateway health/bootstrap gates complete. await finalizeStage(prompter, state, configService);
const finalizeResult = await finalizeStage(prompter, state, configService, {
deferSummary: true,
});
// Gateway stages // Gateway stages
if (!options.skipGateway) { if (!options.skipGateway) {
@@ -485,15 +447,11 @@ async function runKeepPath(
if (!bootstrapResult.completed) { if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.'); prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1); process.exit(1);
return;
} }
finalizeResult.showSummary();
} }
} catch (err) { } catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`); prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err; throw err;
} }
} else {
finalizeResult.showSummary();
} }
} }
-222
View File
@@ -1,222 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-next-install-test-XXXXXX")"
trap 'rm -rf "$TMP"' EXIT
FAKE_BIN="$TMP/bin"
HOME_DIR="$TMP/home"
PREFIX="$TMP/prefix"
MOSAIC_HOME="$TMP/mosaic"
STATE="$TMP/state"
LOG="$TMP/npm.log"
mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE"
cat > "$FAKE_BIN/npm" <<'FAKE_NPM'
#!/usr/bin/env bash
set -euo pipefail
LOG="${MOSAIC_TEST_NPM_LOG:?}"
STATE="${MOSAIC_TEST_STATE:?}"
echo "$*" >> "$LOG"
if [[ "$1" == "view" ]]; then
case "$2 $3" in
"@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;;
"@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;;
"@mosaicstack/mosaic version") echo "0.0.48" ;;
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
esac
exit 0
fi
if [[ "$1" == "install" ]]; then
case "$*" in
*"@mosaicstack/[email protected]"*)
echo "0.0.49-next.999" > "$STATE/mosaic"
;;
*"@mosaicstack/[email protected]"*)
if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then
echo "forced gateway install failure" >&2
exit 1
fi
echo "0.0.7-next.999" > "$STATE/gateway"
;;
*"mosaicstack-mosaic-0.0.0-source.tgz"*)
echo "0.0.0-source" > "$STATE/mosaic"
;;
*"mosaicstack-gateway-0.0.0-source.tgz"*)
echo "0.0.0-source" > "$STATE/gateway"
;;
*) echo "unexpected npm install: $*" >&2; exit 1 ;;
esac
exit 0
fi
if [[ "$1" == "ls" ]]; then
cli="$(cat "$STATE/mosaic" 2>/dev/null || true)"
gateway="$(cat "$STATE/gateway" 2>/dev/null || true)"
node -e '
const cli = process.argv[1];
const gateway = process.argv[2];
const dependencies = {};
if (cli) dependencies["@mosaicstack/mosaic"] = { version: cli };
if (gateway) dependencies["@mosaicstack/gateway"] = { version: gateway };
process.stdout.write(JSON.stringify({ dependencies }));
' "$cli" "$gateway"
exit 0
fi
echo "unexpected npm command: $*" >&2
exit 1
FAKE_NPM
chmod +x "$FAKE_BIN/npm"
cat > "$FAKE_BIN/curl" <<'FAKE_CURL'
#!/usr/bin/env bash
set -euo pipefail
# The fake tar creates the source tree; curl only needs to keep the pipe alive.
exit 0
FAKE_CURL
chmod +x "$FAKE_BIN/curl"
cat > "$FAKE_BIN/tar" <<'FAKE_TAR'
#!/usr/bin/env bash
set -euo pipefail
dest=""
while [[ $# -gt 0 ]]; do
case "$1" in
-C) dest="$2"; shift 2 ;;
*) shift ;;
esac
done
if [[ -z "$dest" ]]; then
echo "fake tar missing -C destination" >&2
exit 1
fi
mkdir -p "$dest/stack/packages/mosaic" "$dest/stack/apps/gateway"
FAKE_TAR
chmod +x "$FAKE_BIN/tar"
cat > "$FAKE_BIN/pnpm" <<'FAKE_PNPM'
#!/usr/bin/env bash
set -euo pipefail
LOG="${MOSAIC_TEST_NPM_LOG:?}"
echo "pnpm $*" >> "$LOG"
if [[ "$1" == "pack" ]]; then
out=""
while [[ $# -gt 0 ]]; do
case "$1" in
--pack-destination) out="$2"; shift 2 ;;
*) shift ;;
esac
done
if [[ -z "$out" ]]; then
echo "fake pnpm pack missing destination" >&2
exit 1
fi
mkdir -p "$out"
case "$PWD" in
*/apps/gateway) touch "$out/mosaicstack-gateway-0.0.0-source.tgz" ;;
*/packages/mosaic) touch "$out/mosaicstack-mosaic-0.0.0-source.tgz" ;;
*) echo "unexpected pnpm pack cwd: $PWD" >&2; exit 1 ;;
esac
exit 0
fi
# install/build commands are no-ops in this harness.
exit 0
FAKE_PNPM
chmod +x "$FAKE_BIN/pnpm"
reset_state() {
: > "$LOG"
rm -f "$STATE"/*
}
reset_state
echo "[test] --next fast path pins resolved package versions"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
)"
grep -qF 'Installed @next packages: CLI 0.0.49-next.999, gateway 0.0.7-next.999' <<<"$OUTPUT"
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
if grep -qE '^install -g .+@next( |$)' "$LOG"; then
echo "expected exact-version installs, found mutable @next install" >&2
exit 1
fi
if grep -qF 'Downloading source from next' <<<"$OUTPUT"; then
echo "fast path unexpectedly fell back to source" >&2
exit 1
fi
reset_state
echo "[test] fast path failure falls back to source build"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
)"
grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT"
grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT"
grep -qF 'Downloading source from next' <<<"$OUTPUT"
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG"
grep -qE 'install -g .*/mosaicstack-mosaic-0\.0\.0-source\.tgz' "$LOG"
[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]]
[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]]
reset_state
echo "[test] explicit --ref keeps source lane and avoids @next lookup"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x
)"
grep -qF 'explicit ref wins, build-from-source' <<<"$OUTPUT"
if grep -qF '@next version' "$LOG"; then
echo "explicit ref should not query @next dist-tags" >&2
exit 1
fi
reset_state
echo "[test] --check --next warns on mismatched prerelease pipeline suffixes"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
MOSAIC_TEST_GATEWAY_NEXT_VERSION="0.0.7-next.1000" \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --check --cli --next
)"
grep -qF '@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source.' <<<"$OUTPUT"
echo "[test] installer next lane tests passed"
+11 -192
View File
@@ -16,10 +16,6 @@
# --framework Install/upgrade framework only (skip npm CLI) # --framework Install/upgrade framework only (skip npm CLI)
# --cli Install/upgrade npm CLI only (skip framework) # --cli Install/upgrade npm CLI only (skip framework)
# --ref <branch> Git ref for framework archive (default: main) # --ref <branch> Git ref for framework archive (default: main)
# --next Prerelease lane: try fast npm @next install for CLI +
# gateway from the Gitea registry, then fall back to a
# source build at next if unavailable. Explicit
# --ref/MOSAIC_REF wins and uses the source path.
# --dev Build CLI + gateway FROM SOURCE at --ref instead of the # --dev Build CLI + gateway FROM SOURCE at --ref instead of the
# registry @latest. Zero registry writes — packs local # registry @latest. Zero registry writes — packs local
# tarballs and installs them globally. Use to test a branch # tarballs and installs them globally. Use to test a branch
@@ -35,7 +31,6 @@
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global) # MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
# MOSAIC_NO_COLOR — disable colour (set to 1) # MOSAIC_NO_COLOR — disable colour (set to 1)
# MOSAIC_REF — git ref for framework (default: main) # MOSAIC_REF — git ref for framework (default: main)
# MOSAIC_NEXT — equivalent to --next (set to 1)
# MOSAIC_DEV — equivalent to --dev (set to 1) # MOSAIC_DEV — equivalent to --dev (set to 1)
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1) # MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
@@ -54,12 +49,7 @@ FLAG_NO_AUTO_LAUNCH=false
FLAG_YES=false FLAG_YES=false
FLAG_UNINSTALL=false FLAG_UNINSTALL=false
FLAG_DEV=false FLAG_DEV=false
FLAG_NEXT=false
GIT_REF="${MOSAIC_REF:-main}" GIT_REF="${MOSAIC_REF:-main}"
GIT_REF_EXPLICIT=false
if [[ -n "${MOSAIC_REF:-}" ]]; then
GIT_REF_EXPLICIT=true
fi
# MOSAIC_ASSUME_YES env var acts the same as --yes # MOSAIC_ASSUME_YES env var acts the same as --yes
if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then
@@ -71,18 +61,8 @@ if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
FLAG_DEV=true FLAG_DEV=true
fi fi
# MOSAIC_NEXT env var acts the same as --next: fast npm @next install with
# source fallback from the permanent next integration branch unless
# MOSAIC_REF/--ref explicitly wins.
if [[ "${MOSAIC_NEXT:-0}" == "1" ]]; then
FLAG_NEXT=true
if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then
GIT_REF="next"
fi
fi
installer_usage() { installer_usage() {
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2 printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2
} }
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@@ -102,11 +82,9 @@ while [[ $# -gt 0 ]]; do
exit 2 exit 2
fi fi
GIT_REF="$2" GIT_REF="$2"
GIT_REF_EXPLICIT=true
shift 2 shift 2
;; ;;
--dev) FLAG_DEV=true; shift ;; --dev) FLAG_DEV=true; shift ;;
--next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;;
--yes|-y) FLAG_YES=true; shift ;; --yes|-y) FLAG_YES=true; shift ;;
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;; --no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
--uninstall) FLAG_UNINSTALL=true; shift ;; --uninstall) FLAG_UNINSTALL=true; shift ;;
@@ -118,24 +96,12 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
# Explicit refs represent a request for that exact source tree. Keep --next as
# a lane selector, but do not install the registry @next package for a different
# ref than the permanent next branch.
if [[ "$FLAG_NEXT" == "true" && "$GIT_REF_EXPLICIT" == "true" ]]; then
FLAG_DEV=true
fi
if [[ "$FLAG_YES" == "true" ]]; then
export MOSAIC_ASSUME_YES=1
fi
# ─── constants ──────────────────────────────────────────────────────────────── # ─── constants ────────────────────────────────────────────────────────────────
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}" REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}"
SCOPE="${MOSAIC_SCOPE:-@mosaicstack}" SCOPE="${MOSAIC_SCOPE:-@mosaicstack}"
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}" PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
CLI_PKG="${SCOPE}/mosaic" CLI_PKG="${SCOPE}/mosaic"
GATEWAY_PKG="${SCOPE}/gateway"
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack" REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack"
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz" ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
@@ -150,20 +116,6 @@ fi
WORK_DIR="" WORK_DIR=""
EXTRACTED_DIR="" EXTRACTED_DIR=""
newest_matching_file() {
local dir="$1"
local pattern="$2"
local matches=()
[[ -d "$dir" ]] || return 0
shopt -s nullglob
# shellcheck disable=SC2206 # Intentional glob expansion for caller-provided file pattern.
matches=("$dir"/$pattern)
shopt -u nullglob
[[ "${#matches[@]}" -gt 0 ]] || return 0
# shellcheck disable=SC2012 # Need portable mtime sorting across Linux/macOS.
ls -1t "${matches[@]}" 2>/dev/null | head -1
}
# ─── uninstall path ─────────────────────────────────────────────────────────── # ─── uninstall path ───────────────────────────────────────────────────────────
# Shell-level uninstall for when the CLI is broken or not available. # Shell-level uninstall for when the CLI is broken or not available.
# Handles: framework directory, npm CLI package, npmrc scope line. # Handles: framework directory, npm CLI package, npmrc scope line.
@@ -227,7 +179,7 @@ if [[ "$FLAG_UNINSTALL" == "true" ]]; then
# Find most recent backup # Find most recent backup
backup="" backup=""
if [[ -d "$dir" ]]; then if [[ -d "$dir" ]]; then
backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")" backup="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
fi fi
if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
cp "$backup" "$dest" cp "$backup" "$dest"
@@ -283,22 +235,6 @@ fail() { echo "${R}✖${RESET} $*" >&2; }
dim() { echo "${DIM}$*${RESET}"; } dim() { echo "${DIM}$*${RESET}"; }
step() { printf '\n%s%s%s\n' "$BOLD" "$*" "$RESET"; } step() { printf '\n%s%s%s\n' "$BOLD" "$*" "$RESET"; }
is_next_registry_lane() {
[[ "$FLAG_NEXT" == "true" && "$FLAG_DEV" == "false" && "$GIT_REF" == "next" && "$GIT_REF_EXPLICIT" == "false" ]]
}
source_ref_details() {
if is_next_registry_lane; then
echo "ref: next, --next prerelease lane"
elif [[ "$FLAG_NEXT" == "true" && "$GIT_REF" == "next" ]]; then
echo "ref: next, --next prerelease lane (build-from-source)"
elif [[ "$FLAG_NEXT" == "true" ]]; then
echo "ref: ${GIT_REF}, --next requested, explicit ref wins"
else
echo "ref: ${GIT_REF}"
fi
}
# ─── helpers ────────────────────────────────────────────────────────────────── # ─── helpers ──────────────────────────────────────────────────────────────────
require_cmd() { require_cmd() {
@@ -321,43 +257,10 @@ installed_cli_version() {
fi fi
} }
installed_gateway_version() {
local json
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
if [[ -n "$json" ]]; then
node -e "
const d = JSON.parse(process.argv[1]);
const v = d?.dependencies?.['${GATEWAY_PKG}']?.version ?? '';
process.stdout.write(v);
" "$json" 2>/dev/null || true
fi
}
latest_cli_version() { latest_cli_version() {
npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true
} }
next_cli_version() {
npm view "${CLI_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
}
next_gateway_version() {
npm view "${GATEWAY_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
}
next_pipeline_suffix() {
printf '%s' "$1" | sed -n 's/.*-next\.\([0-9][0-9]*\)$/\1/p'
}
next_versions_share_pipeline() {
local cli_next="$1"
local gateway_next="$2"
local cli_pipeline gateway_pipeline
cli_pipeline="$(next_pipeline_suffix "$cli_next")"
gateway_pipeline="$(next_pipeline_suffix "$gateway_next")"
[[ -n "$cli_pipeline" && -n "$gateway_pipeline" && "$cli_pipeline" == "$gateway_pipeline" ]]
}
version_lt() { version_lt() {
node -e " node -e "
const a=process.argv[1], b=process.argv[2]; const a=process.argv[1], b=process.argv[2];
@@ -450,8 +353,8 @@ install_cli_from_source() {
( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /' ( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
local cli_tgz gw_tgz local cli_tgz gw_tgz
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')" cli_tgz="$(ls -1t "$out_dir"/mosaicstack-mosaic-*.tgz 2>/dev/null | head -1)"
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')" gw_tgz="$(ls -1t "$out_dir"/mosaicstack-gateway-*.tgz 2>/dev/null | head -1)"
if [[ ! -f "$cli_tgz" ]]; then if [[ ! -f "$cli_tgz" ]]; then
fail "CLI tarball was not produced by pnpm pack." fail "CLI tarball was not produced by pnpm pack."
@@ -473,49 +376,6 @@ install_cli_from_source() {
ok "Installed from source: CLI $(installed_cli_version)" ok "Installed from source: CLI $(installed_cli_version)"
} }
install_next_cli_from_registry() {
local cli_next gateway_next
cli_next="$(next_cli_version)"
gateway_next="$(next_gateway_version)"
if [[ -z "$cli_next" ]]; then
warn "${CLI_PKG}@next is unavailable from $REGISTRY."
return 1
fi
if [[ -z "$gateway_next" ]]; then
warn "${GATEWAY_PKG}@next is unavailable from $REGISTRY."
return 1
fi
if ! next_versions_share_pipeline "$cli_next" "$gateway_next"; then
warn "@next CLI/gateway versions do not share a pipeline suffix (${cli_next}, ${gateway_next})."
return 1
fi
info "Installing ${CLI_PKG}@${cli_next} from registry…"
if ! npm install -g "${CLI_PKG}@${cli_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
warn "Fast CLI @next install failed."
return 1
fi
info "Installing ${GATEWAY_PKG}@${gateway_next} from registry…"
if ! npm install -g "${GATEWAY_PKG}@${gateway_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
warn "Fast gateway @next install failed."
return 1
fi
local installed_cli installed_gateway
installed_cli="$(installed_cli_version)"
installed_gateway="$(installed_gateway_version)"
if [[ "$installed_cli" != "$cli_next" || "$installed_gateway" != "$gateway_next" ]]; then
warn "Installed @next versions did not match resolved versions (CLI: ${installed_cli:-missing}, gateway: ${installed_gateway:-missing})."
return 1
fi
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
}
# ─── preflight ──────────────────────────────────────────────────────────────── # ─── preflight ────────────────────────────────────────────────────────────────
require_cmd node require_cmd node
@@ -549,7 +409,7 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
else else
dim " Installed: (none)" dim " Installed: (none)"
fi fi
dim " Source: ${REPO_BASE} ($(source_ref_details))" dim " Source: ${REPO_BASE} (ref: ${GIT_REF})"
echo "" echo ""
if [[ "$FLAG_CHECK" == "true" ]]; then if [[ "$FLAG_CHECK" == "true" ]]; then
@@ -616,12 +476,8 @@ if [[ "$FLAG_CLI" == "true" ]]; then
fi fi
CURRENT="$(installed_cli_version)" CURRENT="$(installed_cli_version)"
NEXT_GATEWAY=""
if [[ "$FLAG_DEV" == "true" ]]; then if [[ "$FLAG_DEV" == "true" ]]; then
LATEST="" LATEST=""
elif is_next_registry_lane; then
LATEST="$(next_cli_version)"
NEXT_GATEWAY="$(next_gateway_version)"
else else
LATEST="$(latest_cli_version)" LATEST="$(latest_cli_version)"
fi fi
@@ -633,19 +489,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then
fi fi
if [[ "$FLAG_DEV" == "true" ]]; then if [[ "$FLAG_DEV" == "true" ]]; then
dim " Source: ${REPO_BASE} ($(source_ref_details), build-from-source)" dim " Source: ${REPO_BASE} (ref: ${GIT_REF}, build-from-source)"
elif is_next_registry_lane; then
if [[ -n "$LATEST" ]]; then
dim " Next CLI: ${CLI_PKG}@${LATEST}"
else
dim " Next CLI: (registry @next unreachable)"
fi
if [[ -n "$NEXT_GATEWAY" ]]; then
dim " Next GW: ${GATEWAY_PKG}@${NEXT_GATEWAY}"
else
dim " Next GW: (registry @next unreachable)"
fi
dim " Fallback: ${REPO_BASE} (ref: next, build-from-source)"
elif [[ -n "$LATEST" ]]; then elif [[ -n "$LATEST" ]]; then
dim " Latest: ${CLI_PKG}@${LATEST}" dim " Latest: ${CLI_PKG}@${LATEST}"
else else
@@ -656,12 +500,6 @@ if [[ "$FLAG_CLI" == "true" ]]; then
if [[ "$FLAG_CHECK" == "true" ]]; then if [[ "$FLAG_CHECK" == "true" ]]; then
if [[ "$FLAG_DEV" == "true" ]]; then if [[ "$FLAG_DEV" == "true" ]]; then
info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)." info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)."
elif is_next_registry_lane; then
if [[ -n "$LATEST" && -n "$NEXT_GATEWAY" ]] && next_versions_share_pipeline "$LATEST" "$NEXT_GATEWAY"; then
ok "@next registry lane available: ${CLI_PKG}@${LATEST}, ${GATEWAY_PKG}@${NEXT_GATEWAY}."
else
warn "@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source."
fi
elif [[ -z "$LATEST" ]]; then elif [[ -z "$LATEST" ]]; then
warn "Could not reach registry." warn "Could not reach registry."
elif [[ -z "$CURRENT" ]]; then elif [[ -z "$CURRENT" ]]; then
@@ -678,23 +516,6 @@ if [[ "$FLAG_CLI" == "true" ]]; then
ensure_monorepo ensure_monorepo
install_cli_from_source install_cli_from_source
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
elif is_next_registry_lane; then
info "Next mode — trying fast npm @next install from ${REGISTRY}"
if install_next_cli_from_registry; then
:
else
warn "Falling back to source build at ref ${GIT_REF}; --next will not hard-fail on registry issues."
unset MOSAIC_GATEWAY_SKIP_NPM_INSTALL
ensure_monorepo
install_cli_from_source
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
fi
# PATH check for npm prefix # PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH" warn "$PREFIX/bin is not on your PATH"
@@ -803,7 +624,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
local base dir backup_path backup_val local base dir backup_path backup_val
base="$(basename "$dest")" base="$(basename "$dest")"
dir="$(dirname "$dest")" dir="$(dirname "$dest")"
backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")" backup_path="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
if [[ -n "$backup_path" ]]; then if [[ -n "$backup_path" ]]; then
backup_val="\"$backup_path\"" backup_val="\"$backup_path\""
else else
@@ -828,7 +649,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]" NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]"
fi fi
if node -e " node -e "
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const p = process.argv[1]; const p = process.argv[1];
@@ -853,11 +674,9 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
"$MANIFEST_CLI_VERSION" \ "$MANIFEST_CLI_VERSION" \
"$MANIFEST_FW_VERSION" \ "$MANIFEST_FW_VERSION" \
"$NPMRC_LINES_JSON" \ "$NPMRC_LINES_JSON" \
"$RUNTIME_COPIES" 2>/dev/null; then "$RUNTIME_COPIES" 2>/dev/null \
ok "Install manifest written: $MANIFEST_PATH" && ok "Install manifest written: $MANIFEST_PATH" \
else || warn "Could not write install manifest (non-fatal)"
warn "Could not write install manifest (non-fatal)"
fi
echo "" echo ""
ok "Done." ok "Done."