From 838701bde2e265ae980a70ad8e59bfe2f44aaeb9 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Thu, 25 Jun 2026 03:44:54 +0000 Subject: [PATCH 01/13] feat(#462): add federation get verb (#683) FED-M3-06 get verb. Trust boundary mirrors M3-05 AND-intersect (note returned only when owned by subject AND on an authorized mission). Reviewed (review-of-record APPROVE, head 80a259b2) + green PR-event CI 1620. Co-Authored-By: Claude Opus 4.8 --- .../src/federation/federation.module.ts | 12 +- .../verbs/__tests__/get-query.service.spec.ts | 348 ++++++++++++++++++ .../verbs/__tests__/get.controller.spec.ts | 207 +++++++++++ .../server/verbs/get-query.service.ts | 311 ++++++++++++++++ .../federation/server/verbs/get.controller.ts | 100 +++++ docs/scratchpads/462-fed-m3-06-get-verb.md | 38 ++ 6 files changed, 1015 insertions(+), 1 deletion(-) create mode 100644 apps/gateway/src/federation/server/verbs/__tests__/get-query.service.spec.ts create mode 100644 apps/gateway/src/federation/server/verbs/__tests__/get.controller.spec.ts create mode 100644 apps/gateway/src/federation/server/verbs/get-query.service.ts create mode 100644 apps/gateway/src/federation/server/verbs/get.controller.ts create mode 100644 docs/scratchpads/462-fed-m3-06-get-verb.md diff --git a/apps/gateway/src/federation/federation.module.ts b/apps/gateway/src/federation/federation.module.ts index a1ed09de..2fe9f77c 100644 --- a/apps/gateway/src/federation/federation.module.ts +++ b/apps/gateway/src/federation/federation.module.ts @@ -5,6 +5,8 @@ import { EnrollmentController } from './enrollment.controller.js'; import { EnrollmentService } from './enrollment.service.js'; import { FederationController } from './federation.controller.js'; import { CapabilitiesController } from './server/verbs/capabilities.controller.js'; +import { GetController } from './server/verbs/get.controller.js'; +import { FederationGetQueryService } from './server/verbs/get-query.service.js'; import { GrantsService } from './grants.service.js'; import { FederationClientService, QuerySourceService } from './client/index.js'; import { FederationAuthGuard, FederationScopeService } from './server/index.js'; @@ -12,7 +14,13 @@ import { ListController } from './server/verbs/list.controller.js'; import { FederationListQueryService } from './server/verbs/list-query.service.js'; @Module({ - controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController], + controllers: [ + EnrollmentController, + FederationController, + CapabilitiesController, + ListController, + GetController, + ], providers: [ AdminGuard, CaService, @@ -23,6 +31,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js FederationAuthGuard, FederationScopeService, FederationListQueryService, + FederationGetQueryService, ], exports: [ CaService, @@ -33,6 +42,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js FederationAuthGuard, FederationScopeService, FederationListQueryService, + FederationGetQueryService, ], }) export class FederationModule {} diff --git a/apps/gateway/src/federation/server/verbs/__tests__/get-query.service.spec.ts b/apps/gateway/src/federation/server/verbs/__tests__/get-query.service.spec.ts new file mode 100644 index 00000000..7c78f3c0 --- /dev/null +++ b/apps/gateway/src/federation/server/verbs/__tests__/get-query.service.spec.ts @@ -0,0 +1,348 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + createPgliteDb, + missionTasks, + missions, + projects, + runPgliteMigrations, + teams, + users, + type Db, + type DbHandle, +} from '@mosaicstack/db'; +import type { FederationScopeQueryFilter } from '../../scope.service.js'; +import { FederationGetQueryService } from '../get-query.service.js'; + +const CREDENTIAL_FILTER: FederationScopeQueryFilter = { + resource: 'credentials', + subjectUserId: 'user-1', + includePersonal: true, + teamIds: [], + limit: 1, + maxRowsPerQuery: 25, +}; + +const SUBJECT_USER_ID = 'fed-m3-06-subject'; +const OTHER_USER_ID = 'fed-m3-06-other'; +const TEAM_ID = '06000000-0000-4000-8000-000000000001'; +const UNAUTHORIZED_TEAM_ID = '06000000-0000-4000-8000-000000000002'; +const PERSONAL_PROJECT_ID = '06000000-0000-4000-8000-000000000101'; +const TEAM_PROJECT_ID = '06000000-0000-4000-8000-000000000102'; +const UNAUTHORIZED_PROJECT_ID = '06000000-0000-4000-8000-000000000103'; +const PERSONAL_MISSION_ID = '06000000-0000-4000-8000-000000000201'; +const TEAM_MISSION_ID = '06000000-0000-4000-8000-000000000202'; +const UNAUTHORIZED_MISSION_ID = '06000000-0000-4000-8000-000000000203'; +const SUBJECT_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000301'; +const OTHER_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000302'; +const SUBJECT_PERSONAL_NOTE_ID = '06000000-0000-4000-8000-000000000303'; +const SUBJECT_UNAUTHORIZED_NOTE_ID = '06000000-0000-4000-8000-000000000304'; + +let dbHandle: DbHandle | undefined; + +function makeService() { + return new FederationGetQueryService({} as Db); +} + +function makeDbService() { + if (!dbHandle) { + throw new Error('test DB not initialized'); + } + return new FederationGetQueryService(dbHandle.db); +} + +async function seedNotesFixture() { + if (!dbHandle) { + throw new Error('test DB not initialized'); + } + + await dbHandle.db.insert(users).values([ + { + id: SUBJECT_USER_ID, + name: 'Federation Subject', + email: `${SUBJECT_USER_ID}@example.test`, + emailVerified: false, + }, + { + id: OTHER_USER_ID, + name: 'Federation Other', + email: `${OTHER_USER_ID}@example.test`, + emailVerified: false, + }, + ]); + + await dbHandle.db.insert(teams).values([ + { + id: TEAM_ID, + name: 'FED-M3-06 Team', + slug: 'fed-m3-06-team', + ownerId: SUBJECT_USER_ID, + managerId: SUBJECT_USER_ID, + }, + { + id: UNAUTHORIZED_TEAM_ID, + name: 'FED-M3-06 Unauthorized Team', + slug: 'fed-m3-06-unauthorized-team', + ownerId: OTHER_USER_ID, + managerId: OTHER_USER_ID, + }, + ]); + + await dbHandle.db.insert(projects).values([ + { + id: PERSONAL_PROJECT_ID, + name: 'FED-M3-06 Personal Project', + ownerId: SUBJECT_USER_ID, + ownerType: 'user', + }, + { + id: TEAM_PROJECT_ID, + name: 'FED-M3-06 Team Project', + teamId: TEAM_ID, + ownerType: 'team', + }, + { + id: UNAUTHORIZED_PROJECT_ID, + name: 'FED-M3-06 Unauthorized Project', + teamId: UNAUTHORIZED_TEAM_ID, + ownerType: 'team', + }, + ]); + + await dbHandle.db.insert(missions).values([ + { + id: PERSONAL_MISSION_ID, + name: 'FED-M3-06 Personal Mission', + projectId: PERSONAL_PROJECT_ID, + userId: SUBJECT_USER_ID, + }, + { + id: TEAM_MISSION_ID, + name: 'FED-M3-06 Team Mission', + projectId: TEAM_PROJECT_ID, + userId: SUBJECT_USER_ID, + }, + { + id: UNAUTHORIZED_MISSION_ID, + name: 'FED-M3-06 Unauthorized Mission', + projectId: UNAUTHORIZED_PROJECT_ID, + userId: SUBJECT_USER_ID, + }, + ]); + + await dbHandle.db.insert(missionTasks).values([ + { + id: SUBJECT_TEAM_NOTE_ID, + missionId: TEAM_MISSION_ID, + userId: SUBJECT_USER_ID, + notes: 'subject note on team mission', + createdAt: new Date('2026-06-24T03:00:00.000Z'), + updatedAt: new Date('2026-06-24T03:00:00.000Z'), + }, + { + id: OTHER_TEAM_NOTE_ID, + missionId: TEAM_MISSION_ID, + userId: OTHER_USER_ID, + notes: 'other user note on team mission', + createdAt: new Date('2026-06-24T02:00:00.000Z'), + updatedAt: new Date('2026-06-24T02:00:00.000Z'), + }, + { + id: SUBJECT_PERSONAL_NOTE_ID, + missionId: PERSONAL_MISSION_ID, + userId: SUBJECT_USER_ID, + notes: 'subject note on personal mission', + createdAt: new Date('2026-06-24T01:00:00.000Z'), + updatedAt: new Date('2026-06-24T01:00:00.000Z'), + }, + { + id: SUBJECT_UNAUTHORIZED_NOTE_ID, + missionId: UNAUTHORIZED_MISSION_ID, + userId: SUBJECT_USER_ID, + notes: 'subject note outside grant-visible missions', + createdAt: new Date('2026-06-24T04:00:00.000Z'), + updatedAt: new Date('2026-06-24T04:00:00.000Z'), + }, + ]); +} + +describe('FederationGetQueryService', () => { + beforeAll(async () => { + dbHandle = createPgliteDb(`memory://fed-m3-06-get-${Date.now()}`); + await runPgliteMigrations(dbHandle); + await seedNotesFixture(); + }); + + afterAll(async () => { + await dbHandle?.close(); + dbHandle = undefined; + }); + + it('denies sensitive resources in native RBAC for M3 get reads', async () => { + const service = makeService(); + + await expect( + service.evaluateReadAccess({ + grantId: 'grant-1', + peerId: 'peer-1', + subjectUserId: 'user-1', + resource: 'credentials', + }), + ).resolves.toMatchObject({ + allowed: false, + reason: 'credentials federation get access is not implemented in M3', + }); + }); + + it('allows personal memory reads without requiring team lookup', async () => { + const service = makeService(); + + await expect( + service.evaluateReadAccess({ + grantId: 'grant-1', + peerId: 'peer-1', + subjectUserId: 'user-1', + resource: 'memory', + }), + ).resolves.toEqual({ + allowed: true, + access: { includePersonal: true, teamIds: [] }, + }); + }); + + it('uses subject team membership as the native RBAC upper bound for task and note reads', async () => { + const service = makeService(); + const listSubjectTeamIds = vi.fn().mockResolvedValue(['team-1', 'team-2']); + ( + service as unknown as { + listSubjectTeamIds: (subjectUserId: string) => Promise; + } + ).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', + }); + }); +}); diff --git a/apps/gateway/src/federation/server/verbs/__tests__/get.controller.spec.ts b/apps/gateway/src/federation/server/verbs/__tests__/get.controller.spec.ts new file mode 100644 index 00000000..022b59eb --- /dev/null +++ b/apps/gateway/src/federation/server/verbs/__tests__/get.controller.spec.ts @@ -0,0 +1,207 @@ +import 'reflect-metadata'; +import { RequestMethod } from '@nestjs/common'; +import type { FastifyRequest } from 'fastify'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FederationAuthGuard } from '../../federation-auth.guard.js'; +import type { + FederationScopeEvaluationResult, + FederationScopeQueryFilter, +} from '../../scope.service.js'; +import { GetController } from '../get.controller.js'; +import type { FederationGetQueryResult } from '../get-query.service.js'; + +const FEDERATION_CONTEXT = { + grantId: 'grant-1', + peerId: 'peer-1', + subjectUserId: 'user-1', + scope: { resources: ['tasks'], max_rows_per_query: 25 }, +}; + +const TASK_FILTER: FederationScopeQueryFilter = { + resource: 'tasks', + subjectUserId: 'user-1', + includePersonal: true, + teamIds: ['team-1'], + limit: 1, + maxRowsPerQuery: 25, +}; + +function makeRequest(): FastifyRequest { + return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest; +} + +function allowedScope( + filter: FederationScopeQueryFilter = TASK_FILTER, +): FederationScopeEvaluationResult { + return { allowed: true, filter }; +} + +function makeController(opts?: { + scopeResult?: FederationScopeEvaluationResult; + queryResult?: FederationGetQueryResult; +}) { + const scope = { + evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()), + }; + const query = { + evaluateReadAccess: vi.fn(), + get: vi.fn().mockResolvedValue( + opts?.queryResult ?? { + status: 'found', + item: { + id: 'task-1', + title: 'Federated task', + createdAt: new Date('2026-06-24T00:00:00.000Z'), + }, + }, + ), + }; + + return { + controller: new GetController(scope as never, query as never), + scope, + query, + }; +} + +describe('GetController', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('declares POST /api/federation/v1/get/:resource/:id protected only by FederationAuthGuard', () => { + expect(Reflect.getMetadata('path', GetController)).toBe('api/federation/v1/get'); + expect(Reflect.getMetadata('path', GetController.prototype.get)).toBe(':resource/:id'); + expect(Reflect.getMetadata('method', GetController.prototype.get)).toBe(RequestMethod.POST); + expect(Reflect.getMetadata('__guards__', GetController)).toEqual([FederationAuthGuard]); + }); + + it('runs AuthGuard context through ScopeService and returns one local-source tagged row', async () => { + const { controller, scope, query } = makeController(); + + const response = await controller.get('tasks', 'task-1', makeRequest()); + + expect(scope.evaluateAccess).toHaveBeenCalledWith({ + context: FEDERATION_CONTEXT, + resource: 'tasks', + requestedLimit: 1, + nativeRbac: query, + }); + expect(query.get).toHaveBeenCalledWith({ filter: TASK_FILTER, id: 'task-1' }); + expect(response).toEqual({ + item: { + id: 'task-1', + title: 'Federated task', + createdAt: new Date('2026-06-24T00:00:00.000Z'), + _source: 'local', + }, + }); + }); + + it('returns a federation error envelope when auth guard context is missing', async () => { + const { controller, scope, query } = makeController(); + + await expect( + controller.get('tasks', 'task-1', {} as unknown as FastifyRequest), + ).rejects.toMatchObject({ + response: { + error: { + code: 'unauthorized', + message: 'Federation context missing', + }, + }, + status: 401, + }); + expect(scope.evaluateAccess).not.toHaveBeenCalled(); + expect(query.get).not.toHaveBeenCalled(); + }); + + it('returns a federation error envelope when scope evaluation denies access', async () => { + const { controller, query } = makeController({ + scopeResult: { + allowed: false, + deny: { + code: 'resource_excluded', + stage: 'resource_exclusion', + statusCode: 403, + message: 'Requested federation resource is explicitly excluded by grant scope', + grantId: 'grant-1', + peerId: 'peer-1', + subjectUserId: 'user-1', + resource: 'credentials', + }, + }, + }); + + await expect(controller.get('credentials', 'cred-1', makeRequest())).rejects.toMatchObject({ + response: { + error: { + code: 'scope_violation', + message: 'Requested federation resource is explicitly excluded by grant scope', + }, + }, + status: 403, + }); + expect(query.get).not.toHaveBeenCalled(); + }); + + it('returns 404 when the scoped query layer cannot find the resource id', async () => { + const { controller } = makeController({ queryResult: { status: 'not_found' } }); + + await expect(controller.get('tasks', 'missing-task', makeRequest())).rejects.toMatchObject({ + response: { error: { code: 'not_found' } }, + status: 404, + }); + }); + + it('returns 403 when the resource exists outside the RBAC/scope intersection', async () => { + const { controller } = makeController({ + queryResult: { status: 'denied', reason: 'Task is outside the federated scope' }, + }); + + await expect(controller.get('tasks', 'task-2', makeRequest())).rejects.toMatchObject({ + response: { + error: { + code: 'scope_violation', + message: 'Task is outside the federated scope', + }, + }, + status: 403, + }); + }); + + it('fails closed when the query layer denies an unsupported resource', async () => { + const unsupportedFilter: FederationScopeQueryFilter = { + ...TASK_FILTER, + resource: 'unknown-resource' as FederationScopeQueryFilter['resource'], + }; + const { controller } = makeController({ + scopeResult: allowedScope(unsupportedFilter), + queryResult: { + status: 'denied', + reason: 'Unsupported federation get resource: unknown-resource', + }, + }); + + await expect(controller.get('unknown-resource', 'row-1', makeRequest())).rejects.toMatchObject({ + response: { + error: { + code: 'scope_violation', + message: 'Unsupported federation get resource: unknown-resource', + }, + }, + status: 403, + }); + }); + + it('rejects empty ids before evaluating scope', async () => { + const { controller, scope, query } = makeController(); + + await expect(controller.get('tasks', ' ', makeRequest())).rejects.toMatchObject({ + response: { error: { code: 'invalid_request' } }, + status: 400, + }); + expect(scope.evaluateAccess).not.toHaveBeenCalled(); + expect(query.get).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/gateway/src/federation/server/verbs/get-query.service.ts b/apps/gateway/src/federation/server/verbs/get-query.service.ts new file mode 100644 index 00000000..d439c885 --- /dev/null +++ b/apps/gateway/src/federation/server/verbs/get-query.service.ts @@ -0,0 +1,311 @@ +/** + * Federation get query layer (FED-M3-06). + * + * Read-only DB adapter used by GetController after FederationAuthGuard and + * FederationScopeService have established the subject user, allowed resource, + * native-RBAC intersection, and row cap. Audit writes are intentionally + * deferred to M4. + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { + and, + eq, + inArray, + insights, + or, + missionTasks, + missions, + preferences, + projects, + tasks, + teamMembers, + type Db, +} from '@mosaicstack/db'; +import { DB } from '../../../database/database.module.js'; +import type { + FederationNativeRbacEvaluator, + FederationNativeRbacRequest, + FederationNativeRbacResult, + FederationScopeQueryFilter, +} from '../scope.service.js'; + +export interface FederationGetQueryRequest { + readonly filter: FederationScopeQueryFilter; + readonly id: string; +} + +export interface FederationGetQueryFoundResult> { + 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> = + | FederationGetQueryFoundResult + | FederationGetQueryNotFoundResult + | FederationGetQueryDeniedResult; + +type RowObject = Record; + +function firstRow(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 { + 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( + request: FederationGetQueryRequest, + ): Promise> { + return this.getByResource(request.filter, request.id) as Promise>; + } + + private async getByResource( + filter: FederationScopeQueryFilter, + id: string, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 => 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 }; + } +} diff --git a/apps/gateway/src/federation/server/verbs/get.controller.ts b/apps/gateway/src/federation/server/verbs/get.controller.ts new file mode 100644 index 00000000..07e52cb9 --- /dev/null +++ b/apps/gateway/src/federation/server/verbs/get.controller.ts @@ -0,0 +1,100 @@ +/** + * Federation get verb (FED-M3-06). + * + * POST /api/federation/v1/get/:resource/:id + * + * Pipeline: FederationAuthGuard attaches the active grant context, then + * FederationScopeService enforces grant scope + native RBAC intersection, then + * the read-only query layer fetches one local row and tags it with `_source`. + * Read audit-log writes are deferred to M4; this controller does not persist + * request or response bodies. + */ + +import { Controller, HttpException, Inject, Param, Post, Req, UseGuards } from '@nestjs/common'; +import type { FastifyRequest } from 'fastify'; +import { + FederationInvalidRequestError, + FederationNotFoundError, + FederationScopeViolationError, + FederationUnauthorizedError, + SOURCE_LOCAL, + type FederationGetResponse, + type SourceTag, +} from '@mosaicstack/types'; +import { FederationAuthGuard } from '../federation-auth.guard.js'; +import '../federation-context.js'; +import { FederationScopeService } from '../scope.service.js'; +import { FederationGetQueryService } from './get-query.service.js'; + +type FederatedRow = Record & 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> { + 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 } }; + } +} diff --git a/docs/scratchpads/462-fed-m3-06-get-verb.md b/docs/scratchpads/462-fed-m3-06-get-verb.md new file mode 100644 index 00000000..f355565a --- /dev/null +++ b/docs/scratchpads/462-fed-m3-06-get-verb.md @@ -0,0 +1,38 @@ +# Scratchpad — FED-M3-06 get verb + +## Objective + +Implement `POST /api/federation/v1/get/:resource/:id` for M3 inbound federation reads. + +## Scope + +- `apps/gateway/src/federation/server/verbs/get.controller.ts` +- `apps/gateway/src/federation/server/verbs/get-query.service.ts` +- Unit coverage for controller pipeline + query service RBAC guardrails +- Register controller/service in `FederationModule` + +## Plan + +1. Mirror the list verb pipeline: `FederationAuthGuard` → `FederationScopeService` → read-only query service. +2. Return one `_source: "local"` tagged item on success. +3. Return federation error envelopes: + - `404 not_found` when the resource id does not exist. + - `403 scope_violation` when the row exists but falls outside native RBAC/scope intersection. + - `400 invalid_request` for malformed ids/scope requests. +4. Keep read audit persistence deferred to M4; no body or response persistence in M3. + +## Verification Evidence + +- Rebased onto `origin/main` at `86e106fcc9a1dfa3a18f7846bb477be128794aad` after M3-05 merged; resolved `FederationModule` by registering both list and get verb controllers/services. +- Review-change coverage added for comment 15971: + - get note access now requires subject ownership AND authorized mission intersection. + - missing federation context returns structured `401 unauthorized` envelope. + - unsupported get resources fail closed with structured denial. + - PGlite regressions cover cross-user note exclusion and subject-note unauthorized-mission exclusion. +- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/verbs/__tests__/get.controller.spec.ts src/federation/server/verbs/__tests__/get-query.service.spec.ts` — pass (2 files / 17 tests; re-run after review changes). +- `pnpm --filter @mosaicstack/gateway build` — pass (re-run after review changes). +- `pnpm build` — pass (23 successful tasks before review changes). +- `pnpm typecheck` — pass (41 successful tasks; re-run after review changes). +- `pnpm lint` — pass (23 successful tasks; re-run after review changes). +- `pnpm format:check` — pass (re-run after review changes). +- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings after review changes. -- 2.54.0 From a3c1ab923c6e4a6190b49e544d175195654d5bd9 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Thu, 25 Jun 2026 04:14:56 +0000 Subject: [PATCH 02/13] test(#462): add federation M3 integration coverage (#685) FED-M3-10 integration tests for the federation M3 verbs (list/get/scope). Test-infra + docs only; green PR-event CI 1623 (all steps incl ci-postgres). Co-Authored-By: Claude Opus 4.8 --- .../federation-m3-list.integration.test.ts | 519 ++++++++++++++++++ .../FED-M3-10-integration-tests.md | 60 ++ 2 files changed, 579 insertions(+) create mode 100644 apps/gateway/src/__tests__/integration/federation-m3-list.integration.test.ts create mode 100644 docs/scratchpads/FED-M3-10-integration-tests.md diff --git a/apps/gateway/src/__tests__/integration/federation-m3-list.integration.test.ts b/apps/gateway/src/__tests__/integration/federation-m3-list.integration.test.ts new file mode 100644 index 00000000..b597a8f9 --- /dev/null +++ b/apps/gateway/src/__tests__/integration/federation-m3-list.integration.test.ts @@ -0,0 +1,519 @@ +/** + * Federation M3 single-gateway integration tests (FED-M3-10). + * + * Covers MILESTONES.md M3 acceptance: + * - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403. + * - #7: max_rows_per_query caps list results. + * + * Strategy: + * - Real PostgreSQL via @mosaicstack/db. + * - Mocked TLS context/Fastify request shim for FederationAuthGuard. + * - Direct controller calls using the real POST /api/federation/v1/list/:resource contract. + * + * Run: + * FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \ + * src/__tests__/integration/federation-m3-list.integration.test.ts + */ + +import 'reflect-metadata'; +import * as crypto from 'node:crypto'; +import type { ExecutionContext } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { + and, + createDb, + eq, + federationGrants, + federationPeers, + inArray, + missionTasks, + missions, + projects, + tasks, + teamMembers, + teams, + type Db, + type DbHandle, + users, +} from '@mosaicstack/db'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { DB } from '../../database/database.module.js'; +import { GrantsService } from '../../federation/grants.service.js'; +import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js'; +import { FederationScopeService } from '../../federation/server/scope.service.js'; +import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js'; +import { ListController } from '../../federation/server/verbs/list.controller.js'; +import { + makeMosaicIssuedCert, + makeSelfSignedCert, +} from '../../federation/__tests__/helpers/test-cert.js'; + +const run = process.env['FEDERATED_INTEGRATION'] === '1'; +const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic'; +const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`; +const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase(); + +interface TestIds { + readonly subjectUserId: string; + readonly otherUserId: string; + readonly peerId: string; + readonly revokedPeerId: string; + readonly activeGrantId: string; + readonly revokedGrantId: string; + readonly subjectProjectId: string; + readonly subjectMissionId: string; + readonly otherProjectId: string; + readonly teamId: string; + readonly unauthorizedTeamId: string; + readonly teamProjectId: string; + readonly taskIds: readonly string[]; + readonly excludedTaskIds: readonly string[]; + readonly subjectNoteId: string; + readonly otherUserNoteId: string; +} + +function pemToDer(pem: string): Buffer { + return Buffer.from( + pem + .replace(/-----BEGIN CERTIFICATE-----/, '') + .replace(/-----END CERTIFICATE-----/, '') + .replace(/\s+/g, ''), + 'base64', + ); +} + +function makeFederationRequest(certPem: string): FastifyRequest { + return { + raw: { + socket: { + getPeerCertificate: () => ({ + raw: pemToDer(certPem), + serialNumber: CERT_SERIAL_HEX, + }), + }, + }, + } as unknown as FastifyRequest; +} + +function makeGuardContext(request: FastifyRequest): { + readonly context: ExecutionContext; + readonly sent: { statusCode?: number; payload?: unknown }; +} { + const sent: { statusCode?: number; payload?: unknown } = {}; + const reply = { + status: (statusCode: number) => { + sent.statusCode = statusCode; + return { + header: () => ({ + send: (payload: unknown) => { + sent.payload = payload; + }, + }), + }; + }, + } as unknown as FastifyReply; + + const context = { + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => reply, + }), + } as unknown as ExecutionContext; + + return { context, sent }; +} + +async function insertUser(db: Db, id: string, label: string): Promise { + 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 { + 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 { + 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, + }); + }); +}); diff --git a/docs/scratchpads/FED-M3-10-integration-tests.md b/docs/scratchpads/FED-M3-10-integration-tests.md new file mode 100644 index 00000000..eaeb4163 --- /dev/null +++ b/docs/scratchpads/FED-M3-10-integration-tests.md @@ -0,0 +1,60 @@ +# FED-M3-10 — Federation M3 Integration Tests + +## Objective + +Add single-gateway gateway integration tests for M3 acceptance #6 and #7. + +## Branch / base + +- Branch: `feat/federation-m3-integration` +- Base: `origin/next` (`838701bd` after M3-06/#683 merge) +- PR base when unblocked: `next` + +## Scope + +- Real PostgreSQL via `@mosaicstack/db`. +- Mocked TLS context / Fastify request shim for `FederationAuthGuard`. +- Direct controller calls using the real M3 route contract: `POST /api/federation/v1/list/:resource` with body `{ limit?, cursor? }`. +- Gated by `FEDERATED_INTEGRATION=1`. +- No federation harness dependency. + +## Fixture notes + +Aligned with the B2 seed design vocabulary: + +- `tasks` visibility uses personal `projects` + `missions` chain. +- `notes` are `mission_tasks.notes`; the integration suite asserts subject-only note visibility on an authorized mission. +- Seed includes a second user and unauthorized team/project tasks to prove exclusion from the max-row-cap list result. +- Grants/peers are direct DB fixtures; cert auth still runs through `FederationAuthGuard` using real X.509 certs generated by existing test helpers. + +## Current implementation + +Added `apps/gateway/src/__tests__/integration/federation-m3-list.integration.test.ts` covering: + +1. M3 #6 — cert missing Mosaic OIDs returns 401 federation `unauthorized` envelope. +2. M3 #6 — valid cert whose grant row is `revoked` returns 403 federation `forbidden` envelope. +3. M3 #7 — active grant with `max_rows_per_query: 2` caps `list tasks`, returns `_truncated` + `nextCursor`, source-tags rows, and excludes other-user / unauthorized-team tasks. +4. Cross-user notes invariant — subject can list their own `mission_tasks.notes` row while another user's note on the same authorized mission is excluded. +5. Unsupported-resource invariant — `list widgets` fails closed with a federation `scope_violation` envelope. + +## Verification + +- `pnpm --filter @mosaicstack/types build` — PASS. +- `pnpm --filter @mosaicstack/db build` — PASS. +- `pnpm --filter @mosaicstack/storage build` — PASS. +- `pnpm --filter @mosaicstack/brain build` — PASS. +- `pnpm --filter @mosaicstack/queue build` — PASS. +- `pnpm --filter @mosaicstack/config build` — PASS. +- `pnpm --filter @mosaicstack/auth build` — PASS. +- `pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS skipped when `FEDERATED_INTEGRATION` unset (5 skipped). +- `FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS (5 tests) after local `docker compose up -d postgres` + `pnpm --filter @mosaicstack/db db:push`. +- `pnpm --filter @mosaicstack/gateway typecheck` — PASS. +- `pnpm --filter @mosaicstack/gateway lint` — PASS. +- `pnpm format:check` — PASS. +- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS; approve, no findings. +- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS; risk level none, no findings. + +## Push / PR + +- #683 landed in `next`; branch rebased onto `origin/next` before push. +- CI is serialized; run queue guard before push. -- 2.54.0 From 94d6538061628c3be4f94d2fd5abd69367e09232 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Thu, 25 Jun 2026 05:14:32 +0000 Subject: [PATCH 03/13] feat(installer): add next integration lane (#686) Add --next installer flag (build-from-source at the next integration branch; MOSAIC_NEXT=1 env equiv; explicit --ref wins). Three-lane install docs (stable @latest / --next prerelease / --dev source) + @next dist-tag pipeline design doc. Green PR-event CI 1626 + review-of-record APPROVE (head 3a5c12a5). Co-Authored-By: Claude Opus 4.8 --- README.md | 14 +++- .../prerelease-next-dist-tag-pipeline.md | 40 +++++++++++ docs/guides/user-guide.md | 14 +++- .../installer-next-lane-20260624.md | 35 +++++++++ packages/mosaic/framework/defaults/README.md | 14 +++- tools/install.sh | 72 ++++++++++++++++--- 6 files changed, 174 insertions(+), 15 deletions(-) create mode 100644 docs/design/prerelease-next-dist-tag-pipeline.md create mode 100644 docs/scratchpads/installer-next-lane-20260624.md diff --git a/README.md b/README.md index dca4667a..1582d839 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,16 @@ This installs both components: | **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` | | **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` | +### Install lanes + +| Lane | Command | Use when | Source | +| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- | +| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` | +| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Build-from-source at `next` | +| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref | + +`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided. + After install, the wizard runs automatically or you can invoke it manually: ```bash @@ -336,7 +346,9 @@ The CLI also performs a background update check on every invocation (cached for bash tools/install.sh --check # Version check only bash tools/install.sh --framework # Framework only (skip npm CLI) bash tools/install.sh --cli # npm CLI only (skip framework) -bash tools/install.sh --ref v1.0 # Install from a specific git ref +bash tools/install.sh --next # Prerelease lane: source build from next +bash tools/install.sh --dev # Contributor lane: source build at --ref/main +bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next) bash tools/install.sh --yes # Non-interactive, accept all defaults bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard ``` diff --git a/docs/design/prerelease-next-dist-tag-pipeline.md b/docs/design/prerelease-next-dist-tag-pipeline.md new file mode 100644 index 00000000..5cf8d8d9 --- /dev/null +++ b/docs/design/prerelease-next-dist-tag-pipeline.md @@ -0,0 +1,40 @@ +# Planned Design — npm `@next` prerelease lane + +Status: **PLANNED / not yet built** + +## Current state + +`tools/install.sh --next` provides a prerelease integration lane by building the Mosaic CLI and gateway from source at the permanent `next` branch. This is correct for validating integration-branch source, but it is slower than the stable npm lane because it downloads the archive, installs workspace dependencies, builds packages, packs local tarballs, and installs those tarballs globally. + +## Medium-term target + +Publish every accepted `next` integration build to the npm registry under the `@next` dist-tag, for example: + +```text +@mosaicstack/mosaic@0.0.49-next.1 +@mosaicstack/mosaic@0.0.49-next.2 +``` + +Then move `tools/install.sh --next` from source-build behavior to a fast npm install: + +```bash +npm install -g @mosaicstack/mosaic@next +``` + +The framework archive should still resolve from the matching `next` source/ref until framework packaging has a registry-backed equivalent. + +## Pipeline shape + +1. Trigger on successful CI for `next`. +2. Compute the next prerelease version from the upcoming stable version plus a monotonic prerelease counter (`0.0.49-next.N`). +3. Build and pack publishable 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. Teach the installer to prefer `@next` for the CLI/gateway prerelease lane once the registry tag is reliable. + +## Guardrails + +- `@next` is mutable prerelease convenience, not a deployment pin. +- Stable installs continue to use `@latest`. +- Contributor validation remains available through `--dev --ref `. +- Pipeline must be reproducible and trace every prerelease package back to the source commit on `next`. diff --git a/docs/guides/user-guide.md b/docs/guides/user-guide.md index c149edd7..0c12580f 100644 --- a/docs/guides/user-guide.md +++ b/docs/guides/user-guide.md @@ -175,8 +175,18 @@ Or use the direct URL: bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh) ``` -The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for -non-interactive use: +The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. + +Install lanes: + +| Lane | Command | Source | +| ------------------------ | ------------------------------------- | -------------------------------------------- | +| Stable | `bash tools/install.sh` | npm `@mosaicstack/mosaic@latest` + `main` | +| Prerelease integration | `bash tools/install.sh --next` | Build-from-source at permanent branch `next` | +| Contributor/source build | `bash tools/install.sh --dev --ref X` | Build-from-source at the requested ref | + +`--next` implies source-build mode at `next`; explicit `--ref` or `MOSAIC_REF` wins. +Flags for non-interactive use: ```bash --yes # Accept all defaults diff --git a/docs/scratchpads/installer-next-lane-20260624.md b/docs/scratchpads/installer-next-lane-20260624.md new file mode 100644 index 00000000..d46c2faa --- /dev/null +++ b/docs/scratchpads/installer-next-lane-20260624.md @@ -0,0 +1,35 @@ +# Scratchpad — installer `--next` lane + +## Objective + +Add a prerelease installer lane for the permanent `next` integration branch. + +## Scope + +- `tools/install.sh` +- README/install documentation +- Follow-up design note for future npm `@next` prerelease publishing + +## Plan + +1. Add `--next` and `MOSAIC_NEXT=1` as source-build shorthand for `next`. +2. Preserve explicit ref precedence: `MOSAIC_REF` and `--ref` win over `--next`. +3. Update installer source display/help text. +4. Document three lanes: + - stable npm `@latest` + - prerelease `--next` + - contributor `--dev --ref X` +5. Run shell and repo gates locally, then hold before push/PR until runner serialization greenlight. + +## Verification + +- `bash -n tools/install.sh` — pass. +- `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable tools/install.sh` — pass. +- `bash tools/install.sh --check --framework --next` — source display shows `ref: next, --next prerelease lane`. +- `bash tools/install.sh --check --cli --next --ref feature-x` — source display shows explicit ref wins. +- `MOSAIC_NEXT=1 MOSAIC_REF=feature-env bash tools/install.sh --check --cli` — source display shows explicit env ref wins. +- `pnpm install --frozen-lockfile --prefer-offline --store-dir /home/jarvis/.local/share/pnpm/store` — pass (local override for repo `.npmrc` CI store path). +- `pnpm typecheck` — pass (41 successful tasks). +- `pnpm lint` — pass (23 successful tasks). +- `pnpm format:check` — pass. +- `bash tools/e2e-install-test.sh` — attempted; current baseline fails during gateway health after stable registry install because Valkey is unavailable in the clean container. The `tools/install.sh --yes --no-auto-launch` stage itself completed before the downstream gateway verification failure. diff --git a/packages/mosaic/framework/defaults/README.md b/packages/mosaic/framework/defaults/README.md index 629c0bfc..1baaaec9 100644 --- a/packages/mosaic/framework/defaults/README.md +++ b/packages/mosaic/framework/defaults/README.md @@ -43,6 +43,16 @@ The installer: - Runs a health audit - Detects existing installs and preserves local files (SOUL.md, USER.md, etc.) +### Install lanes + +| Lane | Command | Use when | Source | +| ------------------------ | ------------------------------------- | ---------------------------------------------- | ------------------------------------------ | +| Stable | `bash tools/install.sh` | You want the released framework and CLI | npm `@mosaicstack/mosaic@latest` + `main` | +| Prerelease integration | `bash tools/install.sh --next` | You want the permanent `next` integration lane | Build-from-source 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 shorthand for source-build mode at `next`; explicit `--ref` or `MOSAIC_REF` wins when both are present. + ## First Run After install, open a new terminal (or `source ~/.bashrc`) and run: @@ -174,7 +184,9 @@ The installer preserves local `SOUL.md`, `USER.md`, `TOOLS.md`, and `memory/` by bash tools/install.sh --check # Version check only bash tools/install.sh --framework # Framework only (skip npm CLI) bash tools/install.sh --cli # npm CLI only (skip framework) -bash tools/install.sh --ref v1.0 # Install from a specific git ref +bash tools/install.sh --next # Prerelease lane: source build from next +bash tools/install.sh --dev # Contributor lane: source build at --ref/main +bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next) ``` ## Universal Skills diff --git a/tools/install.sh b/tools/install.sh index a1c509d6..79dfc4de 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -16,6 +16,9 @@ # --framework Install/upgrade framework only (skip npm CLI) # --cli Install/upgrade npm CLI only (skip framework) # --ref Git ref for framework archive (default: main) +# --next Prerelease lane: build CLI + gateway FROM SOURCE at the +# permanent next integration branch. Shorthand for --dev +# with ref=next; explicit --ref/MOSAIC_REF wins. # --dev Build CLI + gateway FROM SOURCE at --ref instead of the # registry @latest. Zero registry writes — packs local # tarballs and installs them globally. Use to test a branch @@ -31,6 +34,7 @@ # MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global) # MOSAIC_NO_COLOR — disable colour (set to 1) # MOSAIC_REF — git ref for framework (default: main) +# MOSAIC_NEXT — equivalent to --next (set to 1) # MOSAIC_DEV — equivalent to --dev (set to 1) # MOSAIC_ASSUME_YES — equivalent to --yes (set to 1) # ────────────────────────────────────────────────────────────────────────────── @@ -49,7 +53,12 @@ FLAG_NO_AUTO_LAUNCH=false FLAG_YES=false FLAG_UNINSTALL=false FLAG_DEV=false +FLAG_NEXT=false GIT_REF="${MOSAIC_REF:-main}" +GIT_REF_EXPLICIT=false +if [[ -n "${MOSAIC_REF:-}" ]]; then + GIT_REF_EXPLICIT=true +fi # MOSAIC_ASSUME_YES env var acts the same as --yes if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then @@ -61,13 +70,24 @@ if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then FLAG_DEV=true fi +# MOSAIC_NEXT env var acts the same as --next: source build from the +# permanent next integration branch unless MOSAIC_REF/--ref explicitly wins. +if [[ "${MOSAIC_NEXT:-0}" == "1" ]]; then + FLAG_DEV=true + FLAG_NEXT=true + if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then + GIT_REF="next" + fi +fi + while [[ $# -gt 0 ]]; do case "$1" in --check) FLAG_CHECK=true; shift ;; --framework) FLAG_CLI=false; shift ;; --cli) FLAG_FRAMEWORK=false; shift ;; - --ref) GIT_REF="${2:-main}"; shift 2 ;; + --ref) GIT_REF="${2:-main}"; GIT_REF_EXPLICIT=true; shift 2 ;; --dev) FLAG_DEV=true; shift ;; + --next) FLAG_DEV=true; FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;; --yes|-y) FLAG_YES=true; shift ;; --no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;; --uninstall) FLAG_UNINSTALL=true; shift ;; @@ -75,6 +95,10 @@ while [[ $# -gt 0 ]]; do esac done +if [[ "$FLAG_YES" == "true" ]]; then + export MOSAIC_ASSUME_YES=1 +fi + # ─── constants ──────────────────────────────────────────────────────────────── MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}" @@ -95,6 +119,20 @@ fi WORK_DIR="" EXTRACTED_DIR="" +newest_matching_file() { + local dir="$1" + local pattern="$2" + local matches=() + [[ -d "$dir" ]] || return 0 + shopt -s nullglob + # shellcheck disable=SC2206 # Intentional glob expansion for caller-provided file pattern. + matches=("$dir"/$pattern) + shopt -u nullglob + [[ "${#matches[@]}" -gt 0 ]] || return 0 + # shellcheck disable=SC2012 # Need portable mtime sorting across Linux/macOS. + ls -1t "${matches[@]}" 2>/dev/null | head -1 +} + # ─── uninstall path ─────────────────────────────────────────────────────────── # Shell-level uninstall for when the CLI is broken or not available. # Handles: framework directory, npm CLI package, npmrc scope line. @@ -158,7 +196,7 @@ if [[ "$FLAG_UNINSTALL" == "true" ]]; then # Find most recent backup backup="" if [[ -d "$dir" ]]; then - backup="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)" + backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")" fi if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then cp "$backup" "$dest" @@ -214,6 +252,16 @@ fail() { echo "${R}✖${RESET} $*" >&2; } dim() { echo "${DIM}$*${RESET}"; } step() { echo ""; echo "${BOLD}$*${RESET}"; } +source_ref_details() { + if [[ "$FLAG_NEXT" == "true" && "$GIT_REF" == "next" ]]; then + echo "ref: next, --next prerelease lane" + elif [[ "$FLAG_NEXT" == "true" ]]; then + echo "ref: ${GIT_REF}, --next requested, explicit ref wins" + else + echo "ref: ${GIT_REF}" + fi +} + # ─── helpers ────────────────────────────────────────────────────────────────── require_cmd() { @@ -332,8 +380,8 @@ install_cli_from_source() { ( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /' local cli_tgz gw_tgz - cli_tgz="$(ls -1t "$out_dir"/mosaicstack-mosaic-*.tgz 2>/dev/null | head -1)" - gw_tgz="$(ls -1t "$out_dir"/mosaicstack-gateway-*.tgz 2>/dev/null | head -1)" + cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')" + gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')" if [[ ! -f "$cli_tgz" ]]; then fail "CLI tarball was not produced by pnpm pack." @@ -388,7 +436,7 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then else dim " Installed: (none)" fi - dim " Source: ${REPO_BASE} (ref: ${GIT_REF})" + dim " Source: ${REPO_BASE} ($(source_ref_details))" echo "" if [[ "$FLAG_CHECK" == "true" ]]; then @@ -468,7 +516,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then fi if [[ "$FLAG_DEV" == "true" ]]; then - dim " Source: ${REPO_BASE} (ref: ${GIT_REF}, build-from-source)" + dim " Source: ${REPO_BASE} ($(source_ref_details), build-from-source)" elif [[ -n "$LATEST" ]]; then dim " Latest: ${CLI_PKG}@${LATEST}" else @@ -603,7 +651,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then local base dir backup_path backup_val base="$(basename "$dest")" dir="$(dirname "$dest")" - backup_path="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)" + backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")" if [[ -n "$backup_path" ]]; then backup_val="\"$backup_path\"" else @@ -628,7 +676,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]" fi - node -e " + if node -e " const fs = require('fs'); const path = require('path'); const p = process.argv[1]; @@ -653,9 +701,11 @@ if [[ "$FLAG_CHECK" == "false" ]]; then "$MANIFEST_CLI_VERSION" \ "$MANIFEST_FW_VERSION" \ "$NPMRC_LINES_JSON" \ - "$RUNTIME_COPIES" 2>/dev/null \ - && ok "Install manifest written: $MANIFEST_PATH" \ - || warn "Could not write install manifest (non-fatal)" + "$RUNTIME_COPIES" 2>/dev/null; then + ok "Install manifest written: $MANIFEST_PATH" + else + warn "Could not write install manifest (non-fatal)" + fi echo "" ok "Done." -- 2.54.0 From c25a551c2841663ad251476b8c04029f729d1732 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Thu, 25 Jun 2026 05:45:09 +0000 Subject: [PATCH 04/13] ci(#462): add durable next publish pipeline (#687) Durable @next integration-line publish: on next pushes, compute -next. prerelease versions (in-CI, uncommitted) and publish @mosaicstack/* under the next dist-tag; gateway image sha-only on next. Strict guardrails: next-only, never writes latest, never tags from next; main path unchanged. PR-event CI 1631 fully green + review-of-record APPROVE (head b1a887a2). Guardrails independently verified. Co-Authored-By: Claude Opus 4.8 --- .woodpecker/publish.yml | 109 +++++++++++++++++- docs/guides/dev-guide.md | 11 ++ .../B1-next-durable-publish-design.md | 82 +++++++++++++ 3 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 docs/scratchpads/B1-next-durable-publish-design.md diff --git a/.woodpecker/publish.yml b/.woodpecker/publish.yml index cdc84d07..cce35805 100644 --- a/.woodpecker/publish.yml +++ b/.woodpecker/publish.yml @@ -1,5 +1,5 @@ # Build, publish npm packages, and push Docker images -# Runs only on main branch push/tag +# Runs on main for stable publishes and on next for integration-line prereleases/images variables: # Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine + @@ -23,9 +23,21 @@ variables: - 'docs/**' - '**/*.md' - '.woodpecker/**' + - event: [push, manual] + branch: next + - &main_image_build_when + - event: tag + - event: [push, manual] + branch: main + path: + exclude: + - 'packages/mosaic/**' + - 'docs/**' + - '**/*.md' + - '.woodpecker/**' when: - - branch: [main] + - branch: [main, next] event: [push, manual, tag] steps: @@ -103,6 +115,84 @@ steps: depends_on: - build + publish-next-npm: + image: *node_image + # Durable @next integration-line publish. Runs only on next; never writes + # the latest dist-tag and never commits the computed prerelease versions. + when: + - event: [push, manual] + branch: next + environment: + NPM_TOKEN: + from_secret: gitea_token + CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH} + CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER} + commands: + - *enable_pnpm + - | + if [ "$CI_COMMIT_BRANCH" != "next" ]; then + echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2 + exit 1 + fi + if [ -z "$CI_PIPELINE_NUMBER" ]; then + echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2 + exit 1 + fi + echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc + echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc + DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)" + DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);' + node <<'NODE' + const fs = require('node:fs'); + const path = require('node:path'); + + const pipelineNumber = process.env.CI_PIPELINE_NUMBER; + const roots = ['apps', 'packages', 'plugins']; + const updated = []; + + function walk(dir) { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const packagePath = path.join(fullPath, 'package.json'); + if (fs.existsSync(packagePath)) updatePackage(packagePath); + walk(fullPath); + } + } + } + + function updatePackage(packagePath) { + const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return; + const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version); + if (!stableMatch) { + throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'"); + } + const [, major, minor, patch] = stableMatch; + const oldVersion = manifest.version; + manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber; + fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n'); + updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version); + } + + for (const root of roots) walk(root); + if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found'); + console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:'); + for (const line of updated) console.log('[publish-next] ' + line); + NODE + pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next + EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")" + RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)" + if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then + echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2 + exit 1 + fi + echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION" + depends_on: + - build + # TODO: Uncomment when ready to publish to npmjs.org # publish-npmjs: # image: *node_image @@ -134,8 +224,17 @@ steps: - echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json - | DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}" - if [ "$CI_COMMIT_BRANCH" = "main" ]; then + if [ "$CI_COMMIT_BRANCH" = "next" ]; then + if [ -n "$CI_COMMIT_TAG" ]; then + echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2 + exit 1 + fi + echo "[publish] next gateway publish is sha-only" + elif [ "$CI_COMMIT_BRANCH" = "main" ]; then DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest" + elif [ -z "$CI_COMMIT_TAG" ]; then + echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2 + exit 1 fi if [ -n "$CI_COMMIT_TAG" ]; then DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG" @@ -146,7 +245,7 @@ steps: build-appservice: image: gcr.io/kaniko-project/executor:debug - when: *image_build_when + when: *main_image_build_when environment: REGISTRY_USER: from_secret: gitea_username @@ -172,7 +271,7 @@ steps: build-web: image: gcr.io/kaniko-project/executor:debug - when: *image_build_when + when: *main_image_build_when environment: REGISTRY_USER: from_secret: gitea_username diff --git a/docs/guides/dev-guide.md b/docs/guides/dev-guide.md index b44766a2..936c86a4 100644 --- a/docs/guides/dev-guide.md +++ b/docs/guides/dev-guide.md @@ -211,6 +211,17 @@ pnpm format:check && pnpm typecheck && pnpm lint A pre-push hook enforces this mechanically. +### CI Publish Channels + +Woodpecker `.woodpecker/publish.yml` keeps stable and integration-line artifacts separate: + +| Source | npm packages | Gateway image | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `main` push/manual or release tag | committed package versions published to Gitea npm without changing the dist-tag workflow | `gateway:sha-` plus `gateway:latest` on `main`, and the release tag on tag events | +| `next` push/manual | CI-computed prereleases, `-next.`, published with `npm publish --tag next` | `gateway:sha-` 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 diff --git a/docs/scratchpads/B1-next-durable-publish-design.md b/docs/scratchpads/B1-next-durable-publish-design.md new file mode 100644 index 00000000..56da745a --- /dev/null +++ b/docs/scratchpads/B1-next-durable-publish-design.md @@ -0,0 +1,82 @@ +# B1 / @next Durable Publish Pipeline — Design + +## Objective + +Make `next` a durable integration line that publishes the artifacts required by downstream federation boot tests without manual builds. + +Every merge to `next` publishes: + +1. **npm prerelease packages** to the Gitea npm registry with dist-tag `next`. +2. **Gateway container image** tagged only as `gateway:sha-`. + +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 +-next. +``` + +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-` + `latest` on main + tag on tag events | next prerelease npm | +| gateway image | `next` push/manual only | `sha-` 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-` 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. -- 2.54.0 From 940ae3cc417cba908520f7d2b5c75ef9adb7f57c Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Thu, 25 Jun 2026 07:14:24 +0000 Subject: [PATCH 05/13] feat(installer): prefer npm next lane (#688) --next now prefers a fast npm @next install (CLI + gateway from the Gitea registry) and falls back to source build at next if the dist-tag is unavailable. Registry lane gated to non-dev, non-explicit-ref next installs; CLI/gateway prerelease versions must share a pipeline suffix. Adds tools/install-next-lane.test.sh (wired into CI). PR-event CI 1635 fully green + review-of-record APPROVE (functional install test, head 2fd7cfc3). Co-Authored-By: Claude Opus 4.8 --- .../prerelease-next-dist-tag-pipeline.md | 57 +++-- docs/guides/user-guide.md | 12 +- .../installer-next-fast-npm-20260625.md | 40 ++++ package.json | 3 +- packages/mosaic/framework/defaults/README.md | 14 +- tools/install-next-lane.test.sh | 222 ++++++++++++++++++ tools/install.sh | 146 +++++++++++- 7 files changed, 455 insertions(+), 39 deletions(-) create mode 100644 docs/scratchpads/installer-next-fast-npm-20260625.md create mode 100755 tools/install-next-lane.test.sh diff --git a/docs/design/prerelease-next-dist-tag-pipeline.md b/docs/design/prerelease-next-dist-tag-pipeline.md index 5cf8d8d9..7e2ecb15 100644 --- a/docs/design/prerelease-next-dist-tag-pipeline.md +++ b/docs/design/prerelease-next-dist-tag-pipeline.md @@ -1,40 +1,63 @@ -# Planned Design — npm `@next` prerelease lane +# npm `@next` prerelease lane -Status: **PLANNED / not yet built** +Status: **IMPLEMENTED** -## Current state +## Current behavior -`tools/install.sh --next` provides a prerelease integration lane by building the Mosaic CLI and gateway from source at the permanent `next` branch. This is correct for validating integration-branch source, but it is slower than the stable npm lane because it downloads the archive, installs workspace dependencies, builds packages, packs local tarballs, and installs those tarballs globally. +`tools/install.sh --next` provides the prerelease integration lane for the permanent `next` branch. -## Medium-term target +The lane is fast-by-default: -Publish every accepted `next` integration build to the npm registry under the `@next` dist-tag, for example: +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.` 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 -@mosaicstack/mosaic@0.0.49-next.1 -@mosaicstack/mosaic@0.0.49-next.2 +https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ ``` -Then move `tools/install.sh --next` from source-build behavior to a fast npm install: +Observed `next` dist-tags after enabling the pipeline: -```bash -npm install -g @mosaicstack/mosaic@next +```text +@mosaicstack/mosaic@next -> 0.0.49-next.1633 +@mosaicstack/gateway@next -> 0.0.7-next.1633 ``` -The framework archive should still resolve from the matching `next` source/ref until framework packaging has a registry-backed equivalent. +The gateway also publishes a Docker image as `gateway:sha-` 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 ` / `MOSAIC_REF=` wins over `--next` and uses the source path for that exact ref. ## Pipeline shape -1. Trigger on successful CI for `next`. -2. Compute the next prerelease version from the upcoming stable version plus a monotonic prerelease counter (`0.0.49-next.N`). -3. Build and pack publishable packages in CI. +1. Trigger on `next` merges. +2. Compute the next prerelease version from the upcoming stable version plus the Woodpecker pipeline number (`-next.`). +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. Teach the installer to prefer `@next` for the CLI/gateway prerelease lane once the registry tag is reliable. +6. Publish gateway Docker images from `next` as `gateway:sha-` only. ## Guardrails - `@next` is mutable prerelease convenience, not a deployment pin. - Stable installs continue to use `@latest`. - Contributor validation remains available through `--dev --ref `. -- Pipeline must be reproducible and trace every prerelease package back to the source commit on `next`. +- 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. diff --git a/docs/guides/user-guide.md b/docs/guides/user-guide.md index 0c12580f..323159ca 100644 --- a/docs/guides/user-guide.md +++ b/docs/guides/user-guide.md @@ -179,13 +179,13 @@ The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Install lanes: -| Lane | Command | Source | -| ------------------------ | ------------------------------------- | -------------------------------------------- | -| Stable | `bash tools/install.sh` | npm `@mosaicstack/mosaic@latest` + `main` | -| Prerelease integration | `bash tools/install.sh --next` | Build-from-source at permanent branch `next` | -| Contributor/source build | `bash tools/install.sh --dev --ref X` | Build-from-source at the requested ref | +| 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` implies source-build mode at `next`; explicit `--ref` or `MOSAIC_REF` wins. +`--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 diff --git a/docs/scratchpads/installer-next-fast-npm-20260625.md b/docs/scratchpads/installer-next-fast-npm-20260625.md new file mode 100644 index 00000000..503f1eb7 --- /dev/null +++ b/docs/scratchpads/installer-next-fast-npm-20260625.md @@ -0,0 +1,40 @@ +# Installer `--next` fast npm lane — 2026-06-25 + +## Scope + +Flip `tools/install.sh --next` from source-build-first to fast npm `@next` first, with source fallback. + +## Registry reality check + +Gitea npm registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/` + +Verified before implementation: + +- `@mosaicstack/mosaic@next` resolves to `0.0.49-next.1633`. +- `@mosaicstack/gateway@next` resolves to `0.0.7-next.1633`. +- `@mosaicstack/gateway` dist-tags include `latest: 0.0.6` and `next: 0.0.7-next.1633`. +- `apps/gateway/package.json` is non-private and has Gitea npm `publishConfig`. + +Conclusion: the installer can fast-install both CLI and gateway npm packages for `--next`. The gateway Docker `gateway:sha-` 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.` 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=` to `npm install` forces public transitive dependencies (for example `@anthropic-ai/sdk`) to resolve from Gitea and breaks the fast path; the scoped npmrc mapping is the correct split-registry behavior. + +## Verification notes + +- Added `tools/install-next-lane.test.sh` with a fake npm/source harness for exact-version fast install, registry failure source fallback, explicit-ref precedence, and mismatched suffix warning. +- Wired the installer harness into `pnpm test` via `pnpm run test:installer`. +- Real temp-prefix fast install succeeded with `@mosaicstack/gateway@0.0.7-next.1633` and `@mosaicstack/mosaic@0.0.49-next.1633`. diff --git a/package.json b/package.json index fb75bde1..e24725f0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "turbo run dev", "lint": "turbo run lint", "typecheck": "turbo run typecheck", - "test": "turbo run test", + "test": "turbo run test && pnpm run test:installer", + "test:installer": "bash tools/install-next-lane.test.sh", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", "prepare": "husky" diff --git a/packages/mosaic/framework/defaults/README.md b/packages/mosaic/framework/defaults/README.md index 1baaaec9..82f30859 100644 --- a/packages/mosaic/framework/defaults/README.md +++ b/packages/mosaic/framework/defaults/README.md @@ -45,13 +45,13 @@ The installer: ### 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 | Build-from-source 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 | +| 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 shorthand for source-build mode at `next`; explicit `--ref` or `MOSAIC_REF` wins when both are present. +`--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 @@ -184,7 +184,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 --framework # Framework only (skip npm CLI) 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 --next # Prerelease lane: npm @next, source fallback bash tools/install.sh --dev # Contributor lane: source build at --ref/main bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next) ``` diff --git a/tools/install-next-lane.test.sh b/tools/install-next-lane.test.sh new file mode 100755 index 00000000..4dee1340 --- /dev/null +++ b/tools/install-next-lane.test.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-next-install-test-XXXXXX")" +trap 'rm -rf "$TMP"' EXIT + +FAKE_BIN="$TMP/bin" +HOME_DIR="$TMP/home" +PREFIX="$TMP/prefix" +MOSAIC_HOME="$TMP/mosaic" +STATE="$TMP/state" +LOG="$TMP/npm.log" +mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE" + +cat > "$FAKE_BIN/npm" <<'FAKE_NPM' +#!/usr/bin/env bash +set -euo pipefail +LOG="${MOSAIC_TEST_NPM_LOG:?}" +STATE="${MOSAIC_TEST_STATE:?}" +echo "$*" >> "$LOG" + +if [[ "$1" == "view" ]]; then + case "$2 $3" in + "@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;; + "@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;; + "@mosaicstack/mosaic version") echo "0.0.48" ;; + *) echo "unexpected npm view: $*" >&2; exit 1 ;; + esac + exit 0 +fi + +if [[ "$1" == "install" ]]; then + case "$*" in + *"@mosaicstack/mosaic@0.0.49-next.999"*) + echo "0.0.49-next.999" > "$STATE/mosaic" + ;; + *"@mosaicstack/gateway@0.0.7-next.999"*) + if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then + echo "forced gateway install failure" >&2 + exit 1 + fi + echo "0.0.7-next.999" > "$STATE/gateway" + ;; + *"mosaicstack-mosaic-0.0.0-source.tgz"*) + echo "0.0.0-source" > "$STATE/mosaic" + ;; + *"mosaicstack-gateway-0.0.0-source.tgz"*) + echo "0.0.0-source" > "$STATE/gateway" + ;; + *) echo "unexpected npm install: $*" >&2; exit 1 ;; + esac + exit 0 +fi + +if [[ "$1" == "ls" ]]; then + cli="$(cat "$STATE/mosaic" 2>/dev/null || true)" + gateway="$(cat "$STATE/gateway" 2>/dev/null || true)" + node -e ' + const cli = process.argv[1]; + const gateway = process.argv[2]; + const dependencies = {}; + if (cli) dependencies["@mosaicstack/mosaic"] = { version: cli }; + if (gateway) dependencies["@mosaicstack/gateway"] = { version: gateway }; + process.stdout.write(JSON.stringify({ dependencies })); + ' "$cli" "$gateway" + exit 0 +fi + +echo "unexpected npm command: $*" >&2 +exit 1 +FAKE_NPM +chmod +x "$FAKE_BIN/npm" + +cat > "$FAKE_BIN/curl" <<'FAKE_CURL' +#!/usr/bin/env bash +set -euo pipefail +# The fake tar creates the source tree; curl only needs to keep the pipe alive. +exit 0 +FAKE_CURL +chmod +x "$FAKE_BIN/curl" + +cat > "$FAKE_BIN/tar" <<'FAKE_TAR' +#!/usr/bin/env bash +set -euo pipefail +dest="" +while [[ $# -gt 0 ]]; do + case "$1" in + -C) dest="$2"; shift 2 ;; + *) shift ;; + esac +done +if [[ -z "$dest" ]]; then + echo "fake tar missing -C destination" >&2 + exit 1 +fi +mkdir -p "$dest/stack/packages/mosaic" "$dest/stack/apps/gateway" +FAKE_TAR +chmod +x "$FAKE_BIN/tar" + +cat > "$FAKE_BIN/pnpm" <<'FAKE_PNPM' +#!/usr/bin/env bash +set -euo pipefail +LOG="${MOSAIC_TEST_NPM_LOG:?}" +echo "pnpm $*" >> "$LOG" + +if [[ "$1" == "pack" ]]; then + out="" + while [[ $# -gt 0 ]]; do + case "$1" in + --pack-destination) out="$2"; shift 2 ;; + *) shift ;; + esac + done + if [[ -z "$out" ]]; then + echo "fake pnpm pack missing destination" >&2 + exit 1 + fi + mkdir -p "$out" + case "$PWD" in + */apps/gateway) touch "$out/mosaicstack-gateway-0.0.0-source.tgz" ;; + */packages/mosaic) touch "$out/mosaicstack-mosaic-0.0.0-source.tgz" ;; + *) echo "unexpected pnpm pack cwd: $PWD" >&2; exit 1 ;; + esac + exit 0 +fi + +# install/build commands are no-ops in this harness. +exit 0 +FAKE_PNPM +chmod +x "$FAKE_BIN/pnpm" + +reset_state() { + : > "$LOG" + rm -f "$STATE"/* +} + +reset_state +echo "[test] --next fast path pins resolved package versions" +OUTPUT="$( + HOME="$HOME_DIR" \ + MOSAIC_HOME="$MOSAIC_HOME" \ + MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" \ + MOSAIC_TEST_STATE="$STATE" \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch +)" + +grep -qF 'Installed @next packages: CLI 0.0.49-next.999, gateway 0.0.7-next.999' <<<"$OUTPUT" +grep -qF 'install -g @mosaicstack/gateway@0.0.7-next.999' "$LOG" +grep -qF 'install -g @mosaicstack/mosaic@0.0.49-next.999' "$LOG" +if grep -qE '^install -g .+@next( |$)' "$LOG"; then + echo "expected exact-version installs, found mutable @next install" >&2 + exit 1 +fi +if grep -qF 'Downloading source from next' <<<"$OUTPUT"; then + echo "fast path unexpectedly fell back to source" >&2 + exit 1 +fi + +reset_state +echo "[test] fast path failure falls back to source build" +OUTPUT="$( + HOME="$HOME_DIR" \ + MOSAIC_HOME="$MOSAIC_HOME" \ + MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" \ + MOSAIC_TEST_STATE="$STATE" \ + MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch +)" + +grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT" +grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT" +grep -qF 'Downloading source from next' <<<"$OUTPUT" +grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT" +grep -qF 'install -g @mosaicstack/mosaic@0.0.49-next.999' "$LOG" +grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG" +grep -qE 'install -g .*/mosaicstack-mosaic-0\.0\.0-source\.tgz' "$LOG" +[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]] +[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]] + +reset_state +echo "[test] explicit --ref keeps source lane and avoids @next lookup" +OUTPUT="$( + HOME="$HOME_DIR" \ + MOSAIC_HOME="$MOSAIC_HOME" \ + MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" \ + MOSAIC_TEST_STATE="$STATE" \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x +)" + +grep -qF 'explicit ref wins, build-from-source' <<<"$OUTPUT" +if grep -qF '@next version' "$LOG"; then + echo "explicit ref should not query @next dist-tags" >&2 + exit 1 +fi + +reset_state +echo "[test] --check --next warns on mismatched prerelease pipeline suffixes" +OUTPUT="$( + HOME="$HOME_DIR" \ + MOSAIC_HOME="$MOSAIC_HOME" \ + MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" \ + MOSAIC_TEST_STATE="$STATE" \ + MOSAIC_TEST_GATEWAY_NEXT_VERSION="0.0.7-next.1000" \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --check --cli --next +)" + +grep -qF '@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source.' <<<"$OUTPUT" + +echo "[test] installer next lane tests passed" diff --git a/tools/install.sh b/tools/install.sh index 79dfc4de..e8303afb 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -16,9 +16,10 @@ # --framework Install/upgrade framework only (skip npm CLI) # --cli Install/upgrade npm CLI only (skip framework) # --ref Git ref for framework archive (default: main) -# --next Prerelease lane: build CLI + gateway FROM SOURCE at the -# permanent next integration branch. Shorthand for --dev -# with ref=next; explicit --ref/MOSAIC_REF wins. +# --next Prerelease lane: try fast npm @next install for CLI + +# gateway from the Gitea registry, then fall back to a +# source build at next if unavailable. Explicit +# --ref/MOSAIC_REF wins and uses the source path. # --dev Build CLI + gateway FROM SOURCE at --ref instead of the # registry @latest. Zero registry writes — packs local # tarballs and installs them globally. Use to test a branch @@ -70,10 +71,10 @@ if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then FLAG_DEV=true fi -# MOSAIC_NEXT env var acts the same as --next: source build from the -# permanent next integration branch unless MOSAIC_REF/--ref explicitly wins. +# 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_DEV=true FLAG_NEXT=true if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next" @@ -87,7 +88,7 @@ while [[ $# -gt 0 ]]; do --cli) FLAG_FRAMEWORK=false; shift ;; --ref) GIT_REF="${2:-main}"; GIT_REF_EXPLICIT=true; shift 2 ;; --dev) FLAG_DEV=true; shift ;; - --next) FLAG_DEV=true; FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;; + --next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;; --yes|-y) FLAG_YES=true; shift ;; --no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;; --uninstall) FLAG_UNINSTALL=true; shift ;; @@ -95,6 +96,13 @@ while [[ $# -gt 0 ]]; do esac done +# Explicit refs represent a request for that exact source tree. Keep --next as +# a lane selector, but do not install the registry @next package for a different +# ref than the permanent next branch. +if [[ "$FLAG_NEXT" == "true" && "$GIT_REF_EXPLICIT" == "true" ]]; then + FLAG_DEV=true +fi + if [[ "$FLAG_YES" == "true" ]]; then export MOSAIC_ASSUME_YES=1 fi @@ -105,6 +113,7 @@ REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstac SCOPE="${MOSAIC_SCOPE:-@mosaicstack}" PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}" CLI_PKG="${SCOPE}/mosaic" +GATEWAY_PKG="${SCOPE}/gateway" REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack" ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz" @@ -252,9 +261,15 @@ fail() { echo "${R}✖${RESET} $*" >&2; } dim() { echo "${DIM}$*${RESET}"; } step() { echo ""; echo "${BOLD}$*${RESET}"; } +is_next_registry_lane() { + [[ "$FLAG_NEXT" == "true" && "$FLAG_DEV" == "false" && "$GIT_REF" == "next" && "$GIT_REF_EXPLICIT" == "false" ]] +} + source_ref_details() { - if [[ "$FLAG_NEXT" == "true" && "$GIT_REF" == "next" ]]; then + 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 @@ -284,10 +299,43 @@ installed_cli_version() { fi } +installed_gateway_version() { + local json + json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true + if [[ -n "$json" ]]; then + node -e " + const d = JSON.parse(process.argv[1]); + const v = d?.dependencies?.['${GATEWAY_PKG}']?.version ?? ''; + process.stdout.write(v); + " "$json" 2>/dev/null || true + fi +} + latest_cli_version() { npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true } +next_cli_version() { + npm view "${CLI_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true +} + +next_gateway_version() { + npm view "${GATEWAY_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true +} + +next_pipeline_suffix() { + printf '%s' "$1" | sed -n 's/.*-next\.\([0-9][0-9]*\)$/\1/p' +} + +next_versions_share_pipeline() { + local cli_next="$1" + local gateway_next="$2" + local cli_pipeline gateway_pipeline + cli_pipeline="$(next_pipeline_suffix "$cli_next")" + gateway_pipeline="$(next_pipeline_suffix "$gateway_next")" + [[ -n "$cli_pipeline" && -n "$gateway_pipeline" && "$cli_pipeline" == "$gateway_pipeline" ]] +} + version_lt() { node -e " const a=process.argv[1], b=process.argv[2]; @@ -403,6 +451,49 @@ install_cli_from_source() { ok "Installed from source: CLI $(installed_cli_version)" } +install_next_cli_from_registry() { + local cli_next gateway_next + cli_next="$(next_cli_version)" + gateway_next="$(next_gateway_version)" + + if [[ -z "$cli_next" ]]; then + warn "${CLI_PKG}@next is unavailable from $REGISTRY." + return 1 + fi + if [[ -z "$gateway_next" ]]; then + warn "${GATEWAY_PKG}@next is unavailable from $REGISTRY." + return 1 + fi + + if ! next_versions_share_pipeline "$cli_next" "$gateway_next"; then + warn "@next CLI/gateway versions do not share a pipeline suffix (${cli_next}, ${gateway_next})." + return 1 + fi + + info "Installing ${CLI_PKG}@${cli_next} from registry…" + if ! npm install -g "${CLI_PKG}@${cli_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then + warn "Fast CLI @next install failed." + return 1 + fi + + info "Installing ${GATEWAY_PKG}@${gateway_next} from registry…" + if ! npm install -g "${GATEWAY_PKG}@${gateway_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then + warn "Fast gateway @next install failed." + return 1 + fi + + local installed_cli installed_gateway + installed_cli="$(installed_cli_version)" + installed_gateway="$(installed_gateway_version)" + if [[ "$installed_cli" != "$cli_next" || "$installed_gateway" != "$gateway_next" ]]; then + warn "Installed @next versions did not match resolved versions (CLI: ${installed_cli:-missing}, gateway: ${installed_gateway:-missing})." + return 1 + fi + + export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 + ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}" +} + # ─── preflight ──────────────────────────────────────────────────────────────── require_cmd node @@ -503,8 +594,12 @@ if [[ "$FLAG_CLI" == "true" ]]; then fi CURRENT="$(installed_cli_version)" + NEXT_GATEWAY="" if [[ "$FLAG_DEV" == "true" ]]; then LATEST="" + elif is_next_registry_lane; then + LATEST="$(next_cli_version)" + NEXT_GATEWAY="$(next_gateway_version)" else LATEST="$(latest_cli_version)" fi @@ -517,6 +612,18 @@ if [[ "$FLAG_CLI" == "true" ]]; then if [[ "$FLAG_DEV" == "true" ]]; then dim " Source: ${REPO_BASE} ($(source_ref_details), build-from-source)" + elif is_next_registry_lane; then + if [[ -n "$LATEST" ]]; then + dim " Next CLI: ${CLI_PKG}@${LATEST}" + else + dim " Next CLI: (registry @next unreachable)" + fi + if [[ -n "$NEXT_GATEWAY" ]]; then + dim " Next GW: ${GATEWAY_PKG}@${NEXT_GATEWAY}" + else + dim " Next GW: (registry @next unreachable)" + fi + dim " Fallback: ${REPO_BASE} (ref: next, build-from-source)" elif [[ -n "$LATEST" ]]; then dim " Latest: ${CLI_PKG}@${LATEST}" else @@ -527,6 +634,12 @@ if [[ "$FLAG_CLI" == "true" ]]; then if [[ "$FLAG_CHECK" == "true" ]]; then if [[ "$FLAG_DEV" == "true" ]]; then info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)." + elif is_next_registry_lane; then + if [[ -n "$LATEST" && -n "$NEXT_GATEWAY" ]] && next_versions_share_pipeline "$LATEST" "$NEXT_GATEWAY"; then + ok "@next registry lane available: ${CLI_PKG}@${LATEST}, ${GATEWAY_PKG}@${NEXT_GATEWAY}." + else + warn "@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source." + fi elif [[ -z "$LATEST" ]]; then warn "Could not reach registry." elif [[ -z "$CURRENT" ]]; then @@ -543,6 +656,23 @@ if [[ "$FLAG_CLI" == "true" ]]; then ensure_monorepo install_cli_from_source + # PATH check for npm prefix + if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then + warn "$PREFIX/bin is not on your PATH" + dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\"" + fi + elif is_next_registry_lane; then + info "Next mode — trying fast npm @next install from ${REGISTRY}…" + if install_next_cli_from_registry; then + : + else + warn "Falling back to source build at ref ${GIT_REF}; --next will not hard-fail on registry issues." + unset MOSAIC_GATEWAY_SKIP_NPM_INSTALL + ensure_monorepo + install_cli_from_source + export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 + fi + # PATH check for npm prefix if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then warn "$PREFIX/bin is not on your PATH" -- 2.54.0 From 56787fabf14dd7e6257539edf9f0d87d10fccb57 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Thu, 25 Jun 2026 17:17:24 +0000 Subject: [PATCH 06/13] fix(gateway): disable Redis consumers on local tier (#689) --- .../src/admin/admin-health.controller.ts | 15 ++++- .../src/commands/command-executor.service.ts | 23 ++++--- apps/gateway/src/commands/commands.module.ts | 20 ++++-- apps/gateway/src/gc/gc.module.ts | 20 ++++-- apps/gateway/src/gc/session-gc.service.ts | 45 +++++++++---- apps/gateway/src/log/cron.service.ts | 18 +++-- .../preferences/system-override.service.ts | 66 +++++++++++++++++-- apps/gateway/src/queue/queue.service.spec.ts | 35 ++++++++++ apps/gateway/src/queue/queue.service.ts | 64 ++++++++++++++++-- 9 files changed, 256 insertions(+), 50 deletions(-) create mode 100644 apps/gateway/src/queue/queue.service.spec.ts diff --git a/apps/gateway/src/admin/admin-health.controller.ts b/apps/gateway/src/admin/admin-health.controller.ts index 23311439..99fd93f3 100644 --- a/apps/gateway/src/admin/admin-health.controller.ts +++ b/apps/gateway/src/admin/admin-health.controller.ts @@ -1,9 +1,11 @@ -import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; +import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common'; import { sql, type Db } from '@mosaicstack/db'; import { createQueue } from '@mosaicstack/queue'; +import type { MosaicConfig } from '@mosaicstack/config'; import { DB } from '../database/database.module.js'; import { AgentService } from '../agent/agent.service.js'; import { ProviderService } from '../agent/provider.service.js'; +import { MOSAIC_CONFIG } from '../config/config.module.js'; import { AdminGuard } from './admin.guard.js'; import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js'; @@ -14,6 +16,9 @@ export class AdminHealthController { @Inject(DB) private readonly db: Db, @Inject(AgentService) private readonly agentService: AgentService, @Inject(ProviderService) private readonly providerService: ProviderService, + @Optional() + @Inject(MOSAIC_CONFIG) + private readonly mosaicConfig: MosaicConfig | null, ) {} @Get() @@ -55,6 +60,14 @@ export class AdminHealthController { } private async checkCache(): Promise { + // On Local tier there is no Redis. The cache is intentionally absent, which + // is a healthy state for this tier — report 'ok' rather than opening a new + // ioredis connection on every admin health check (which would spam + // ECONNREFUSED and create/destroy a connection per request). latencyMs 0 + // signals "no cache backend to measure" for this tier. + if (this.mosaicConfig?.queue?.type === 'local') { + return { status: 'ok', latencyMs: 0 }; + } const start = Date.now(); const handle = createQueue(); try { diff --git a/apps/gateway/src/commands/command-executor.service.ts b/apps/gateway/src/commands/command-executor.service.ts index 678f0c16..b8c1ce1c 100644 --- a/apps/gateway/src/commands/command-executor.service.ts +++ b/apps/gateway/src/commands/command-executor.service.ts @@ -21,7 +21,10 @@ export class CommandExecutorService { @Inject(AgentService) private readonly agentService: AgentService, @Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService, @Inject(SessionGCService) private readonly sessionGC: SessionGCService, - @Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'], + // On Local tier COMMANDS_REDIS is null — provider login caching is skipped. + @Optional() + @Inject(COMMANDS_REDIS) + private readonly redis: QueueHandle['redis'] | null, @Inject(BRAIN) private readonly brain: Brain, @Optional() @Inject(forwardRef(() => ReloadService)) @@ -403,14 +406,16 @@ export class CommandExecutorService { }; } const pollToken = crypto.randomUUID(); - const key = `mosaic:auth:poll:${pollToken}`; - // Store pending state in Valkey (TTL 5 minutes) - await this.redis.set( - key, - JSON.stringify({ status: 'pending', provider: providerName, userId }), - 'EX', - 300, - ); + const pollKey = `mosaic:auth:poll:${pollToken}`; + if (this.redis) { + // Store pending state in Valkey (TTL 5 minutes) + await this.redis.set( + pollKey, + JSON.stringify({ status: 'pending', provider: providerName, userId }), + 'EX', + 300, + ); + } // In production this would construct an OAuth URL const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`; return { diff --git a/apps/gateway/src/commands/commands.module.ts b/apps/gateway/src/commands/commands.module.ts index 1c3a82ce..1d38faab 100644 --- a/apps/gateway/src/commands/commands.module.ts +++ b/apps/gateway/src/commands/commands.module.ts @@ -1,5 +1,7 @@ -import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common'; +import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common'; import { createQueue, type QueueHandle } from '@mosaicstack/queue'; +import type { MosaicConfig } from '@mosaicstack/config'; +import { MOSAIC_CONFIG } from '../config/config.module.js'; import { ChatModule } from '../chat/chat.module.js'; import { GCModule } from '../gc/gc.module.js'; import { ReloadModule } from '../reload/reload.module.js'; @@ -14,13 +16,17 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE'; providers: [ { provide: COMMANDS_QUEUE_HANDLE, - useFactory: (): QueueHandle => { + useFactory: (config: MosaicConfig | null): QueueHandle | null => { + // On Local tier there is no Redis — skip the ioredis connection. + // CommandExecutorService falls back to no-cache for /provider login on local. + if (config?.queue?.type === 'local') return null; return createQueue(); }, + inject: [MOSAIC_CONFIG], }, { provide: COMMANDS_REDIS, - useFactory: (handle: QueueHandle) => handle.redis, + useFactory: (handle: QueueHandle | null) => handle?.redis ?? null, inject: [COMMANDS_QUEUE_HANDLE], }, CommandRegistryService, @@ -29,9 +35,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE'; exports: [CommandRegistryService, CommandExecutorService], }) export class CommandsModule implements OnApplicationShutdown { - constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {} + constructor( + @Optional() + @Inject(COMMANDS_QUEUE_HANDLE) + private readonly handle: QueueHandle | null, + ) {} async onApplicationShutdown(): Promise { - await this.handle.close().catch(() => {}); + await this.handle?.close().catch(() => {}); } } diff --git a/apps/gateway/src/gc/gc.module.ts b/apps/gateway/src/gc/gc.module.ts index 1f426d10..bacc0584 100644 --- a/apps/gateway/src/gc/gc.module.ts +++ b/apps/gateway/src/gc/gc.module.ts @@ -1,5 +1,7 @@ -import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common'; +import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common'; import { createQueue, type QueueHandle } from '@mosaicstack/queue'; +import type { MosaicConfig } from '@mosaicstack/config'; +import { MOSAIC_CONFIG } from '../config/config.module.js'; import { SessionGCService } from './session-gc.service.js'; import { REDIS } from './gc.tokens.js'; @@ -9,13 +11,17 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE'; providers: [ { provide: GC_QUEUE_HANDLE, - useFactory: (): QueueHandle => { + useFactory: (config: MosaicConfig | null): QueueHandle | null => { + // On Local tier there is no Redis — skip the ioredis connection entirely. + // The Valkey GC sweep is a no-op on Local (no session keys stored there). + if (config?.queue?.type === 'local') return null; return createQueue(); }, + inject: [MOSAIC_CONFIG], }, { provide: REDIS, - useFactory: (handle: QueueHandle) => handle.redis, + useFactory: (handle: QueueHandle | null) => handle?.redis ?? null, inject: [GC_QUEUE_HANDLE], }, SessionGCService, @@ -23,9 +29,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE'; exports: [SessionGCService], }) export class GCModule implements OnApplicationShutdown { - constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {} + constructor( + @Optional() + @Inject(GC_QUEUE_HANDLE) + private readonly handle: QueueHandle | null, + ) {} async onApplicationShutdown(): Promise { - await this.handle.close().catch(() => {}); + await this.handle?.close().catch(() => {}); } } diff --git a/apps/gateway/src/gc/session-gc.service.ts b/apps/gateway/src/gc/session-gc.service.ts index 18d1e39d..00282c13 100644 --- a/apps/gateway/src/gc/session-gc.service.ts +++ b/apps/gateway/src/gc/session-gc.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common'; +import { Inject, Injectable, Logger, Optional, type OnModuleInit } from '@nestjs/common'; import type { QueueHandle } from '@mosaicstack/queue'; import type { LogService } from '@mosaicstack/log'; import { LOG_SERVICE } from '../log/log.tokens.js'; @@ -32,11 +32,21 @@ export class SessionGCService implements OnModuleInit { private readonly logger = new Logger(SessionGCService.name); constructor( - @Inject(REDIS) private readonly redis: QueueHandle['redis'], + // On Local tier there is no Redis — the GC module provides null for this token. + // NOTE: if a future feature stores Redis-backed state on Local tier, this guard + // would silently skip GC for those keys. Revisit when that happens. + @Optional() + @Inject(REDIS) + private readonly redis: QueueHandle['redis'] | null, @Inject(LOG_SERVICE) private readonly logService: LogService, ) {} onModuleInit(): void { + if (!this.redis) { + // Local tier: no Valkey — skip cold-start GC entirely (correct no-op). + this.logger.log('SessionGCService: Valkey GC skipped on local tier (no Redis configured)'); + return; + } // Fire-and-forget: run full GC asynchronously so it does not block the // NestJS bootstrap chain. Cold-start GC typically takes 100–500 ms // depending on Valkey key count; deferring it removes that latency from @@ -60,8 +70,10 @@ export class SessionGCService implements OnModuleInit { * Scan Valkey for all keys matching a pattern using SCAN (non-blocking). * KEYS is avoided because it blocks the Valkey event loop for the full scan * duration, which can cause latency spikes under production key volumes. + * Returns empty array when Redis is not available (Local tier). */ private async scanKeys(pattern: string): Promise { + if (!this.redis) return []; const collected: string[] = []; let cursor = '0'; do { @@ -78,12 +90,14 @@ export class SessionGCService implements OnModuleInit { async collect(sessionId: string): Promise { const result: GCResult = { sessionId, cleaned: {} }; - // 1. Valkey: delete all session-scoped keys - const pattern = `mosaic:session:${sessionId}:*`; - const valkeyKeys = await this.scanKeys(pattern); - if (valkeyKeys.length > 0) { - await this.redis.del(...valkeyKeys); - result.cleaned.valkeyKeys = valkeyKeys.length; + // 1. Valkey: delete all session-scoped keys (skipped on Local tier) + if (this.redis) { + const pattern = `mosaic:session:${sessionId}:*`; + const valkeyKeys = await this.scanKeys(pattern); + if (valkeyKeys.length > 0) { + await this.redis.del(...valkeyKeys); + result.cleaned.valkeyKeys = valkeyKeys.length; + } } // 2. PG: demote hot-tier agent_logs for this session to warm @@ -106,6 +120,7 @@ export class SessionGCService implements OnModuleInit { const cleaned: GCResult[] = []; // 1. Find all session-scoped Valkey keys (non-blocking SCAN) + // Returns empty on Local tier — no Valkey session keys exist there. const allSessionKeys = await this.scanKeys('mosaic:session:*'); // Extract unique session IDs from keys @@ -136,11 +151,15 @@ export class SessionGCService implements OnModuleInit { */ async fullCollect(): Promise { const start = Date.now(); + let valkeyKeysCount = 0; - // 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN) - const sessionKeys = await this.scanKeys('mosaic:session:*'); - if (sessionKeys.length > 0) { - await this.redis.del(...sessionKeys); + if (this.redis) { + // 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN) + const sessionKeys = await this.scanKeys('mosaic:session:*'); + if (sessionKeys.length > 0) { + await this.redis.del(...sessionKeys); + } + valkeyKeysCount = sessionKeys.length; } // 2. NOTE: channel keys are NOT collected on cold start @@ -154,7 +173,7 @@ export class SessionGCService implements OnModuleInit { const jobsPurged = 0; return { - valkeyKeys: sessionKeys.length, + valkeyKeys: valkeyKeysCount, logsDemoted, jobsPurged, tempFilesRemoved: 0, diff --git a/apps/gateway/src/log/cron.service.ts b/apps/gateway/src/log/cron.service.ts index aa9b82fd..4b5ccb8c 100644 --- a/apps/gateway/src/log/cron.service.ts +++ b/apps/gateway/src/log/cron.service.ts @@ -19,7 +19,7 @@ import type { MosaicJobData } from '../queue/queue.service.js'; @Injectable() export class CronService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(CronService.name); - private readonly registeredWorkers: Worker[] = []; + private readonly registeredWorkers: Array> = []; constructor( @Inject(SummarizationService) private readonly summarization: SummarizationService, @@ -28,6 +28,16 @@ export class CronService implements OnModuleInit, OnModuleDestroy { ) {} async onModuleInit(): Promise { + // On Local tier BullMQ is disabled — skip all job scheduling. + // NOTE: this means summarization, tier management, and Valkey GC jobs do not + // run on Local installs. For a single-user local install this is acceptable. + // If periodic background work is needed on Local in the future, add a + // setInterval-based scheduler here. + if (!this.queueService.isEnabled()) { + this.logger.log('CronService: BullMQ disabled on local tier — no jobs will be scheduled'); + return; + } + const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am const gcSchedule = process.env['SESSION_GC_CRON'] ?? '0 4 * * *'; // daily at 4am @@ -42,7 +52,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy { const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => { await this.summarization.runSummarization(); }); - this.registeredWorkers.push(summarizationWorker); + if (summarizationWorker) this.registeredWorkers.push(summarizationWorker); // M6-005: Tier management repeatable job await this.queueService.addRepeatableJob( @@ -54,14 +64,14 @@ export class CronService implements OnModuleInit, OnModuleDestroy { const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => { await this.summarization.runTierManagement(); }); - this.registeredWorkers.push(tierWorker); + if (tierWorker) this.registeredWorkers.push(tierWorker); // M6-004: GC repeatable job await this.queueService.addRepeatableJob(QUEUE_GC, 'session-gc', {}, gcSchedule); const gcWorker = this.queueService.registerWorker(QUEUE_GC, async () => { await this.sessionGC.sweepOrphans(); }); - this.registeredWorkers.push(gcWorker); + if (gcWorker) this.registeredWorkers.push(gcWorker); this.logger.log( `BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`, diff --git a/apps/gateway/src/preferences/system-override.service.ts b/apps/gateway/src/preferences/system-override.service.ts index 5fa48da3..e35ed631 100644 --- a/apps/gateway/src/preferences/system-override.service.ts +++ b/apps/gateway/src/preferences/system-override.service.ts @@ -1,5 +1,7 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common'; import { createQueue, type QueueHandle } from '@mosaicstack/queue'; +import type { MosaicConfig } from '@mosaicstack/config'; +import { MOSAIC_CONFIG } from '../config/config.module.js'; const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`; const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) => @@ -11,16 +13,54 @@ interface OverrideFragment { addedAt: number; } -@Injectable() -export class SystemOverrideService { - private readonly logger = new Logger(SystemOverrideService.name); - private readonly handle: QueueHandle; +interface LocalOverrideEntry { + condensed: string; + fragments: OverrideFragment[]; +} - constructor() { - this.handle = createQueue(); +@Injectable() +export class SystemOverrideService implements OnApplicationShutdown { + private readonly logger = new Logger(SystemOverrideService.name); + private readonly handle: QueueHandle | null; + /** + * In-memory fallback used on Local tier (no Redis). + * NOTE: state is ephemeral — lost on restart. For Local single-user installs + * this is acceptable; system overrides are re-applied at the next session. + * This is a deliberate behavior change from the Redis-backed 7-day TTL. + */ + private readonly localStore = new Map(); + + constructor( + @Optional() + @Inject(MOSAIC_CONFIG) + private readonly mosaicConfig: MosaicConfig | null, + ) { + if (this.mosaicConfig?.queue?.type === 'local') { + this.handle = null; + } else { + this.handle = createQueue(); + } + } + + async onApplicationShutdown(): Promise { + // On non-local tiers the constructor opens an ioredis connection; close it + // on graceful shutdown to avoid leaking the handle (local tier is null). + await this.handle?.close().catch(() => {}); } async set(sessionId: string, override: string): Promise { + if (!this.handle) { + // Local tier: in-memory path + const entry = this.localStore.get(sessionId) ?? { condensed: '', fragments: [] }; + entry.fragments.push({ text: override, addedAt: Date.now() }); + entry.condensed = await this.condenseOverrides(entry.fragments.map((f) => f.text)); + this.localStore.set(sessionId, entry); + this.logger.debug( + `Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`, + ); + return; + } + // Load existing fragments const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId)); const fragments: OverrideFragment[] = existing @@ -50,10 +90,17 @@ export class SystemOverrideService { } async get(sessionId: string): Promise { + if (!this.handle) { + return this.localStore.get(sessionId)?.condensed ?? null; + } return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId)); } async renew(sessionId: string): Promise { + if (!this.handle) { + // Local tier: no TTL to renew; entry persists until restart + return; + } const pipeline = this.handle.redis.pipeline(); pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS); pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS); @@ -61,6 +108,11 @@ export class SystemOverrideService { } async clear(sessionId: string): Promise { + if (!this.handle) { + this.localStore.delete(sessionId); + this.logger.debug(`Cleared system override for session ${sessionId} (local)`); + return; + } await this.handle.redis.del( SESSION_SYSTEM_KEY(sessionId), SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), diff --git a/apps/gateway/src/queue/queue.service.spec.ts b/apps/gateway/src/queue/queue.service.spec.ts new file mode 100644 index 00000000..85f1e641 --- /dev/null +++ b/apps/gateway/src/queue/queue.service.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { MosaicConfig } from '@mosaicstack/config'; +import { QueueService } from './queue.service.js'; + +const localConfig = { + queue: { type: 'local' }, +} as MosaicConfig; + +describe('QueueService local tier', () => { + it('disables BullMQ and treats queue operations as local no-ops', async () => { + const service = new QueueService(null, localConfig); + + expect(service.isEnabled()).toBe(false); + expect(service.getQueue('mosaic-test')).toBeNull(); + expect(service.registerWorker('mosaic-test', vi.fn())).toBeNull(); + + await expect( + service.addRepeatableJob('mosaic-test', 'local-noop', {}, '* * * * *'), + ).resolves.toBeUndefined(); + await expect(service.getHealthStatus()).resolves.toEqual({ queues: {}, healthy: true }); + await expect(service.listJobs()).resolves.toEqual([]); + await expect(service.retryJob('mosaic-test__1')).resolves.toEqual({ + ok: false, + message: 'BullMQ is disabled on local tier.', + }); + await expect(service.pauseQueue('mosaic-test')).resolves.toEqual({ + ok: false, + message: 'BullMQ is disabled on local tier.', + }); + await expect(service.resumeQueue('mosaic-test')).resolves.toEqual({ + ok: false, + message: 'BullMQ is disabled on local tier.', + }); + }); +}); diff --git a/apps/gateway/src/queue/queue.service.ts b/apps/gateway/src/queue/queue.service.ts index a50a773f..6e84340a 100644 --- a/apps/gateway/src/queue/queue.service.ts +++ b/apps/gateway/src/queue/queue.service.ts @@ -8,7 +8,9 @@ import { } from '@nestjs/common'; import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq'; import type { LogService } from '@mosaicstack/log'; +import type { MosaicConfig } from '@mosaicstack/config'; import { LOG_SERVICE } from '../log/log.tokens.js'; +import { MOSAIC_CONFIG } from '../config/config.module.js'; import type { JobDto, JobStatus } from './queue-admin.dto.js'; // --------------------------------------------------------------------------- @@ -108,21 +110,42 @@ export class QueueService implements OnModuleInit, OnModuleDestroy { private readonly connection: ConnectionOptions; private readonly queues = new Map>(); private readonly workers = new Map>(); + /** False on Local tier — BullMQ/Redis operations become no-ops. */ + private readonly enabled: boolean; constructor( @Optional() @Inject(LOG_SERVICE) private readonly logService: LogService | null, + @Optional() + @Inject(MOSAIC_CONFIG) + private readonly mosaicConfig: MosaicConfig | null, ) { - this.connection = getConnection(); + this.enabled = this.mosaicConfig?.queue?.type !== 'local'; + this.connection = this.enabled + ? getConnection() + : ({ host: '127.0.0.1', port: 6380 } as ConnectionOptions); + } + + /** Returns true when BullMQ/Redis is active (Standalone and Federated tiers). */ + isEnabled(): boolean { + return this.enabled; } onModuleInit(): void { - this.logger.log('QueueService initialised (BullMQ)'); + if (this.enabled) { + this.logger.log('QueueService initialised (BullMQ)'); + } else { + this.logger.log( + 'QueueService: BullMQ disabled for local tier — no Redis connections will be opened', + ); + } } async onModuleDestroy(): Promise { - await this.closeAll(); + if (this.enabled) { + await this.closeAll(); + } } // ------------------------------------------------------------------------- @@ -131,8 +154,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy { /** * Get or create a BullMQ Queue for the given queue name. + * Returns null on Local tier where BullMQ is disabled. */ - getQueue(name: string): Queue { + getQueue(name: string): Queue | null { + if (!this.enabled) return null; let queue = this.queues.get(name) as Queue | undefined; if (!queue) { queue = new Queue(name, { connection: this.connection }); @@ -144,6 +169,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy { /** * Add a BullMQ repeatable job (cron-style). * Uses `jobId` as a deterministic key so duplicate registrations are idempotent. + * No-op on Local tier. */ async addRepeatableJob( queueName: string, @@ -151,7 +177,13 @@ export class QueueService implements OnModuleInit, OnModuleDestroy { data: T, cronExpression: string, ): Promise { - const queue = this.getQueue(queueName); + if (!this.enabled) { + this.logger.debug( + `Skipping repeatable job "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`, + ); + return; + } + const queue = this.getQueue(queueName)!; // eslint-disable-next-line @typescript-eslint/no-explicit-any await (queue as Queue).add(jobName, data, { repeat: { pattern: cronExpression }, @@ -165,8 +197,18 @@ export class QueueService implements OnModuleInit, OnModuleDestroy { /** * Register a Worker for the given queue name with error handling and * exponential backoff. + * Returns null on Local tier where BullMQ is disabled. */ - registerWorker(queueName: string, handler: JobHandler): Worker { + registerWorker( + queueName: string, + handler: JobHandler, + ): Worker | null { + if (!this.enabled) { + this.logger.debug( + `Skipping worker registration for "${queueName}" (local tier — BullMQ disabled)`, + ); + return null; + } const worker = new Worker( queueName, async (job) => { @@ -223,8 +265,12 @@ export class QueueService implements OnModuleInit, OnModuleDestroy { /** * Return queue health statistics for all managed queues. + * Returns an empty healthy result on Local tier. */ async getHealthStatus(): Promise { + if (!this.enabled) { + return { queues: {}, healthy: true }; + } const queues: QueueHealthStatus['queues'] = {}; let healthy = true; @@ -255,8 +301,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy { /** * List jobs across all managed queues, optionally filtered by status. * BullMQ jobs are fetched by state type from each queue. + * Returns empty array on Local tier. */ async listJobs(status?: JobStatus): Promise { + if (!this.enabled) return []; const jobs: JobDto[] = []; const states: JobStatus[] = status ? [status] @@ -283,8 +331,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy { * Retry a specific failed job by its BullMQ job ID (format: "queueName:id"). * The caller passes "__" as the composite ID because BullMQ * job IDs are not globally unique — they are scoped to their queue. + * Returns an error on Local tier. */ async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> { + if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' }; const sep = compositeId.lastIndexOf('__'); if (sep === -1) { return { ok: false, message: 'Invalid job id format. Expected "__".' }; @@ -316,6 +366,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy { * Pause a queue by name. */ async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> { + if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' }; const queue = this.queues.get(name); if (!queue) return { ok: false, message: `Queue "${name}" not found.` }; await queue.pause(); @@ -327,6 +378,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy { * Resume a paused queue by name. */ async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> { + if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' }; const queue = this.queues.get(name); if (!queue) return { ok: false, message: `Queue "${name}" not found.` }; await queue.resume(); -- 2.54.0 From 0883fb91ec9f4803c9f82e442737e354adc8b825 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Thu, 25 Jun 2026 17:35:19 +0000 Subject: [PATCH 07/13] fix(wizard): resolve skills sync script path (#690) --- docs/scratchpads/B2-skills-sync-path.md | 34 +++++++++++++++++++ packages/mosaic/framework/defaults/README.md | 14 ++++---- .../mosaic/src/stages/finalize-skills.spec.ts | 33 ++++++++++++++---- packages/mosaic/src/stages/finalize.ts | 19 +++++++++-- 4 files changed, 84 insertions(+), 16 deletions(-) create mode 100644 docs/scratchpads/B2-skills-sync-path.md diff --git a/docs/scratchpads/B2-skills-sync-path.md b/docs/scratchpads/B2-skills-sync-path.md new file mode 100644 index 00000000..763de391 --- /dev/null +++ b/docs/scratchpads/B2-skills-sync-path.md @@ -0,0 +1,34 @@ +# B2 — Fresh-install skills sync path + +## Problem + +Greenfield wizard on `next` reported: + +```text +Skills sync script not found at ~/.config/mosaic/bin/mosaic-sync-skills +Skills: install failed +``` + +## Diagnosis + +The framework install migration removed the legacy `~/.config/mosaic/bin/` directory and now installs framework helper scripts under: + +```text +~/.config/mosaic/tools/_scripts/ +``` + +`packages/mosaic/src/stages/finalize.ts` still resolved wizard helper scripts from `mosaicHome/bin`, so wizard-selected skills failed even though `mosaic-sync-skills` was present in the current framework layout. + +## Fix + +- Resolve framework helper scripts through `tools/_scripts/` first. +- Keep a legacy `bin/` 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. diff --git a/packages/mosaic/framework/defaults/README.md b/packages/mosaic/framework/defaults/README.md index 82f30859..1a598dcb 100644 --- a/packages/mosaic/framework/defaults/README.md +++ b/packages/mosaic/framework/defaults/README.md @@ -118,8 +118,8 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim ├── TOOLS.md ← Machine-level tool reference (generated by mosaic init) ├── STANDARDS.md ← Machine-wide standards ├── guides/ ← Operational guides (E2E delivery, PRD, docs, etc.) -├── bin/ ← CLI tools (mosaic launcher, mosaic-init, mosaic-doctor, etc.) ├── tools/ ← Tool suites: git, orchestrator, prdy, quality, etc. +│ └── _scripts/ ← Framework helper scripts (sync skills, doctor, runtime links) ├── runtime/ ← Runtime adapters + runtime-specific references │ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks │ ├── codex/ ← instructions.md, RUNTIME.md @@ -194,15 +194,15 @@ bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref win The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`, then links each skill into runtime directories. ```bash -mosaic sync # Full sync (clone + link) -~/.config/mosaic/bin/mosaic-sync-skills --link-only # Re-link only +mosaic sync # Full sync (clone + link) +~/.config/mosaic/tools/_scripts/mosaic-sync-skills --link-only # Re-link only ``` ## Health Audit ```bash -mosaic doctor # Standard audit -~/.config/mosaic/bin/mosaic-doctor --fail-on-warn # Strict mode +mosaic doctor # Standard audit +~/.config/mosaic/tools/_scripts/mosaic-doctor --fail-on-warn # Strict mode ``` ## MCP Registration @@ -213,8 +213,8 @@ sequential-thinking MCP is required for Mosaic Stack. The installer registers it To verify or re-register manually: ```bash -~/.config/mosaic/bin/mosaic-ensure-sequential-thinking -~/.config/mosaic/bin/mosaic-ensure-sequential-thinking --check +~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking +~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking --check ``` ### Claude Code MCP Registration diff --git a/packages/mosaic/src/stages/finalize-skills.spec.ts b/packages/mosaic/src/stages/finalize-skills.spec.ts index 61e427ce..e8dd48f8 100644 --- a/packages/mosaic/src/stages/finalize-skills.spec.ts +++ b/packages/mosaic/src/stages/finalize-skills.spec.ts @@ -85,16 +85,16 @@ function makeConfigService(): ConfigService { describe('finalizeStage — skill installer', () => { let tmp: string; - let binDir: string; + let scriptsDir: string; let syncScript: string; beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'mosaic-finalize-')); - binDir = join(tmp, 'bin'); - mkdirSync(binDir, { recursive: true }); - syncScript = join(binDir, 'mosaic-sync-skills'); + scriptsDir = join(tmp, 'tools', '_scripts'); + mkdirSync(scriptsDir, { recursive: true }); + syncScript = join(scriptsDir, 'mosaic-sync-skills'); - // Default: script exists and succeeds + // Default: current framework layout has tools/_scripts and succeeds. writeFileSync(syncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 }); spawnSyncMock.mockReturnValue({ status: 0, stdout: 'ok', stderr: '' }); }); @@ -122,10 +122,29 @@ describe('finalizeStage — skill installer', () => { const call = findSkillsSyncCall(); expect(call).toBeDefined(); + expect(call![1]).toEqual([join(tmp, 'tools', '_scripts', 'mosaic-sync-skills')]); const opts = call![2] as { env?: Record }; expect(opts.env?.['MOSAIC_INSTALL_SKILLS']).toBe('brainstorming:lint:systematic-debugging'); }); + it('falls back to legacy bin path for pre-migration installs', async () => { + rmSync(syncScript); + const legacyBinDir = join(tmp, 'bin'); + mkdirSync(legacyBinDir, { recursive: true }); + const legacySyncScript = join(legacyBinDir, 'mosaic-sync-skills'); + writeFileSync(legacySyncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 }); + + const state = makeState(tmp, ['brainstorming']); + const p = buildPrompter(); + const config = makeConfigService(); + + await finalizeStage(p, state, config); + + const call = findSkillsSyncCall(); + expect(call).toBeDefined(); + expect(call![1]).toEqual([legacySyncScript]); + }); + it('skips the sync script entirely when no skills are selected', async () => { const state = makeState(tmp, []); const p = buildPrompter(); @@ -165,7 +184,9 @@ describe('finalizeStage — skill installer', () => { // spawnSync should NOT have been called for the skills script expect(findSkillsSyncCall()).toBeUndefined(); - expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('not found')); + expect(p.warn).toHaveBeenCalledWith( + expect.stringContaining('tools/_scripts/mosaic-sync-skills'), + ); }); it('includes skills count in the summary when install succeeds', async () => { diff --git a/packages/mosaic/src/stages/finalize.ts b/packages/mosaic/src/stages/finalize.ts index 4835f6eb..cde124b0 100644 --- a/packages/mosaic/src/stages/finalize.ts +++ b/packages/mosaic/src/stages/finalize.ts @@ -7,8 +7,21 @@ import type { ConfigService } from '../config/config-service.js'; import type { WizardState } from '../types.js'; import { getShellProfilePath } from '../platform/detect.js'; +function frameworkScriptPath(mosaicHome: string, name: string): string { + const currentPath = join(mosaicHome, 'tools', '_scripts', name); + if (existsSync(currentPath)) return currentPath; + + // Backward-compatible fallback for pre-migration installs that still have bin/. + const legacyPath = join(mosaicHome, 'bin', name); + if (existsSync(legacyPath)) return legacyPath; + + // Return the current expected path so user-facing errors point at the layout + // installed by packages/mosaic/framework/install.sh. + return currentPath; +} + function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void { - const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets'); + const script = frameworkScriptPath(mosaicHome, 'mosaic-link-runtime-assets'); if (existsSync(script)) { try { spawnSync('bash', [script], { @@ -48,7 +61,7 @@ function syncSkills(mosaicHome: string, selectedSkills: string[]): SyncSkillsRes return { success: true, installedCount: 0 }; } - const script = join(mosaicHome, 'bin', 'mosaic-sync-skills'); + const script = frameworkScriptPath(mosaicHome, 'mosaic-sync-skills'); if (!existsSync(script)) { return { success: false, @@ -96,7 +109,7 @@ interface DoctorResult { } function runDoctor(mosaicHome: string): DoctorResult { - const script = join(mosaicHome, 'bin', 'mosaic-doctor'); + const script = frameworkScriptPath(mosaicHome, 'mosaic-doctor'); if (!existsSync(script)) { return { warnings: 0, output: 'mosaic-doctor not found' }; } -- 2.54.0 From b96cc7982a9828928ab474ebe5dbc890a47a567c Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Thu, 25 Jun 2026 18:14:40 +0000 Subject: [PATCH 08/13] fix(wizard): report gateway failures before success summary (#691) --- .../B3-wizard-gateway-health-order.md | 36 +++++++ .../integration/unified-wizard.test.ts | 45 ++++++++- packages/mosaic/src/runtime/detector.ts | 2 +- packages/mosaic/src/stages/finalize.ts | 99 ++++++++++++------- packages/mosaic/src/stages/quick-start.ts | 30 +++--- packages/mosaic/src/wizard.ts | 52 +++++++--- 6 files changed, 198 insertions(+), 66 deletions(-) create mode 100644 docs/scratchpads/B3-wizard-gateway-health-order.md diff --git a/docs/scratchpads/B3-wizard-gateway-health-order.md b/docs/scratchpads/B3-wizard-gateway-health-order.md new file mode 100644 index 00000000..33452890 --- /dev/null +++ b/docs/scratchpads/B3-wizard-gateway-health-order.md @@ -0,0 +1,36 @@ +# B3 — Wizard completion ordering + +## Problem + +The wizard printed the success summary / `Mosaic is ready.` during `finalizeStage`, before the gateway configuration stage had completed its daemon health check. If the gateway health gate later failed, the user could see a success claim followed by a gateway failure. + +## Diagnosis + +`finalizeStage` handled both mutation work and terminal success messaging. Wizard paths then ran `gatewayConfigStage` and `gatewayBootstrapStage` afterward: + +1. finalize writes config, links runtime assets, syncs skills, runs doctor; +2. finalize prints `Installation Summary` + `Mosaic is ready.`; +3. gateway config starts/waits for daemon health; +4. gateway bootstrap runs. + +The summary needed to be deferred until after the gateway readiness gates. + +## Fix + +- `finalizeStage` now returns a `showSummary()` callback and supports `deferSummary`. +- Wizard/quick-start paths call finalize with `deferSummary: true`. +- `showSummary()` is called only after gateway config reports ready and bootstrap completes, or immediately when the caller explicitly skips gateway setup. +- If gateway health/config reports not ready, the wizard returns/aborts without printing the success summary. +- Folded in adjacent runtime install hint fix for Pi: `curl -fsSL https://pi.dev/install.sh | sh`. + +## Verification + +- Added unified-wizard coverage for summary-after-health and no-summary-on-health-failure. +- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard finalize-skills` +- `pnpm format:check` +- `pnpm typecheck` +- `pnpm lint` +- `pnpm build` +- `pnpm test` +- Codex code review: approve. +- Codex security review: one low finding on the requested Pi `curl | sh` install hint; no security finding in the wizard completion-ordering change. diff --git a/packages/mosaic/__tests__/integration/unified-wizard.test.ts b/packages/mosaic/__tests__/integration/unified-wizard.test.ts index 169f9454..5becc836 100644 --- a/packages/mosaic/__tests__/integration/unified-wizard.test.ts +++ b/packages/mosaic/__tests__/integration/unified-wizard.test.ts @@ -98,8 +98,12 @@ describe('Unified wizard (runWizard with default skipGateway)', () => { expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 }); }); - it('does not invoke bootstrap when config stage reports not ready', async () => { - gatewayConfigMock.mockResolvedValue({ ready: false }); + it('prints the success summary only after gateway health succeeds', async () => { + gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => { + p.log('Gateway is healthy.'); + return { ready: true, host: 'localhost', port: 14242 }; + }); + gatewayBootstrapMock.mockResolvedValue({ completed: true }); const prompter = new HeadlessPrompter({ 'Installation mode': 'quick', @@ -118,6 +122,43 @@ describe('Unified wizard (runWizard with default skipGateway)', () => { skipGatewayNpmInstall: true, }); + const logs = prompter.getLogs(); + const healthIndex = logs.findIndex((line) => line.includes('Gateway is healthy.')); + const summaryIndex = logs.findIndex((line) => line.includes('Installation Summary')); + const readyIndex = logs.findIndex((line) => line.includes('Mosaic is ready.')); + + expect(healthIndex).toBeGreaterThanOrEqual(0); + expect(summaryIndex).toBeGreaterThan(healthIndex); + expect(readyIndex).toBeGreaterThan(summaryIndex); + }); + + it('does not claim success when gateway health reports not ready', async () => { + gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => { + p.warn('Gateway did not become healthy within 30 seconds.'); + return { ready: false }; + }); + + const prompter = new HeadlessPrompter({ + 'Installation mode': 'quick', + 'What name should agents use?': 'TestBot', + 'Communication style': 'direct', + 'Your name': 'Tester', + 'Your pronouns': 'They/Them', + 'Your timezone': 'UTC', + }); + + await runWizard({ + mosaicHome: tmpDir, + sourceDir: tmpDir, + prompter, + configService: createConfigService(tmpDir, tmpDir), + skipGatewayNpmInstall: true, + }); + + const logs = prompter.getLogs(); + expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true); + expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false); + expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false); expect(gatewayConfigMock).toHaveBeenCalledTimes(1); expect(gatewayBootstrapMock).not.toHaveBeenCalled(); }); diff --git a/packages/mosaic/src/runtime/detector.ts b/packages/mosaic/src/runtime/detector.ts index c80167c7..3f4d6a9d 100644 --- a/packages/mosaic/src/runtime/detector.ts +++ b/packages/mosaic/src/runtime/detector.ts @@ -37,7 +37,7 @@ const RUNTIME_DEFS: Record< label: 'Pi', command: 'pi', versionFlag: '--version', - installHint: 'npm install -g @mariozechner/pi-coding-agent', + installHint: 'curl -fsSL https://pi.dev/install.sh | sh', }, }; diff --git a/packages/mosaic/src/stages/finalize.ts b/packages/mosaic/src/stages/finalize.ts index cde124b0..eaaa92a8 100644 --- a/packages/mosaic/src/stages/finalize.ts +++ b/packages/mosaic/src/stages/finalize.ts @@ -162,11 +162,24 @@ function setupPath(mosaicHome: string, _p: WizardPrompter): PathAction { } } +export interface FinalizeStageOptions { + /** + * Defer the success summary/outro so callers can run downstream readiness + * gates (gateway health/bootstrap) before claiming Mosaic is ready. + */ + deferSummary?: boolean; +} + +export interface FinalizeStageResult { + showSummary: () => void; +} + export async function finalizeStage( p: WizardPrompter, state: WizardState, config: ConfigService, -): Promise { + options: FinalizeStageOptions = {}, +): Promise { p.separator(); const spin = p.spinner(); @@ -213,44 +226,56 @@ export async function finalizeStage( // 6. PATH setup const pathAction = setupPath(state.mosaicHome, p); - // 7. Summary - const skillsSummary = skillsResult.success - ? skillsResult.installedCount > 0 - ? `${skillsResult.installedCount.toString()} installed` - : 'none selected' - : `install failed — ${skillsResult.failureReason ?? 'unknown error'}`; + let summaryShown = false; + const showSummary = () => { + if (summaryShown) return; + summaryShown = true; - const summary: string[] = [ - `Agent: ${state.soul.agentName ?? 'Assistant'}`, - `Style: ${state.soul.communicationStyle ?? 'direct'}`, - `Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`, - `Skills: ${skillsSummary}`, - `Config: ${state.mosaicHome}`, - ]; + // 7. Summary + const skillsSummary = skillsResult.success + ? skillsResult.installedCount > 0 + ? `${skillsResult.installedCount.toString()} installed` + : 'none selected' + : `install failed — ${skillsResult.failureReason ?? 'unknown error'}`; - if (doctorResult.warnings > 0) { - summary.push( - `Health: ${doctorResult.warnings.toString()} warning(s) — run 'mosaic doctor' for details`, - ); - } else { - summary.push('Health: all checks passed'); + const summary: string[] = [ + `Agent: ${state.soul.agentName ?? 'Assistant'}`, + `Style: ${state.soul.communicationStyle ?? 'direct'}`, + `Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`, + `Skills: ${skillsSummary}`, + `Config: ${state.mosaicHome}`, + ]; + + if (doctorResult.warnings > 0) { + summary.push( + `Health: ${doctorResult.warnings.toString()} warning(s) — run 'mosaic doctor' for details`, + ); + } else { + summary.push('Health: all checks passed'); + } + + p.note(summary.join('\n'), 'Installation Summary'); + + // 8. Next steps + const nextSteps: string[] = []; + if (pathAction === 'added') { + const profilePath = getShellProfilePath(); + nextSteps.push(`Reload shell: source ${profilePath ?? '~/.profile'}`); + } + if (state.runtimes.detected.length === 0) { + nextSteps.push('Install at least one runtime (claude, codex, or opencode)'); + } + nextSteps.push("Launch with 'mosaic claude' (or codex/opencode)"); + nextSteps.push('Edit identity files directly in ~/.config/mosaic/ for fine-tuning'); + + p.note(nextSteps.map((s, i) => `${(i + 1).toString()}. ${s}`).join('\n'), 'Next Steps'); + + p.outro('Mosaic is ready.'); + }; + + if (!options.deferSummary) { + showSummary(); } - p.note(summary.join('\n'), 'Installation Summary'); - - // 8. Next steps - const nextSteps: string[] = []; - if (pathAction === 'added') { - const profilePath = getShellProfilePath(); - nextSteps.push(`Reload shell: source ${profilePath ?? '~/.profile'}`); - } - if (state.runtimes.detected.length === 0) { - nextSteps.push('Install at least one runtime (claude, codex, or opencode)'); - } - nextSteps.push("Launch with 'mosaic claude' (or codex/opencode)"); - nextSteps.push('Edit identity files directly in ~/.config/mosaic/ for fine-tuning'); - - p.note(nextSteps.map((s, i) => `${(i + 1).toString()}. ${s}`).join('\n'), 'Next Steps'); - - p.outro('Mosaic is ready.'); + return { showSummary }; } diff --git a/packages/mosaic/src/stages/quick-start.ts b/packages/mosaic/src/stages/quick-start.ts index b97d1a4f..f132cc4a 100644 --- a/packages/mosaic/src/stages/quick-start.ts +++ b/packages/mosaic/src/stages/quick-start.ts @@ -58,8 +58,11 @@ export async function quickStartPath( // Skills (recommended set, no user input in quick mode) await skillsSelectStage(prompter, state); - // Finalize (writes configs, links runtime assets, syncs skills) - await finalizeStage(prompter, state, configService); + // Finalize writes configs/assets/skills, but defer the success summary until + // after the gateway health/bootstrap gates complete. + const finalizeResult = await finalizeStage(prompter, state, configService, { + deferSummary: true, + }); // Gateway config + bootstrap if (!options.skipGateway) { @@ -80,19 +83,24 @@ export async function quickStartPath( prompter.warn('Gateway configuration failed in headless mode — aborting wizard.'); process.exit(1); } - } else { - const bootstrapResult = await gatewayBootstrapStage(prompter, state, { - host: configResult.host, - port: configResult.port, - }); - if (!bootstrapResult.completed) { - prompter.warn('Admin bootstrap failed — aborting wizard.'); - process.exit(1); - } + return; } + + const bootstrapResult = await gatewayBootstrapStage(prompter, state, { + host: configResult.host, + port: configResult.port, + }); + if (!bootstrapResult.completed) { + prompter.warn('Admin bootstrap failed — aborting wizard.'); + process.exit(1); + return; + } + finalizeResult.showSummary(); } catch (err) { prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`); throw err; } + } else { + finalizeResult.showSummary(); } } diff --git a/packages/mosaic/src/wizard.ts b/packages/mosaic/src/wizard.ts index 15b3188f..757aeeec 100644 --- a/packages/mosaic/src/wizard.ts +++ b/packages/mosaic/src/wizard.ts @@ -310,8 +310,11 @@ async function runFinishPath( await skillsSelectStage(prompter, state); } - // Finalize (writes configs, links runtime assets, syncs skills) - await finalizeStage(prompter, state, configService); + // Finalize writes configs/assets/skills, but defer the success summary until + // after the gateway health/bootstrap gates complete. + const finalizeResult = await finalizeStage(prompter, state, configService, { + deferSummary: true, + }); // Gateway stages if (!options.skipGateway) { @@ -333,12 +336,16 @@ async function runFinishPath( if (!bootstrapResult.completed) { prompter.warn('Admin bootstrap failed — aborting wizard.'); process.exit(1); + return; } + finalizeResult.showSummary(); } } catch (err) { prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`); throw err; } + } else { + finalizeResult.showSummary(); } } @@ -374,8 +381,11 @@ async function runHeadlessPath( // Skills await skillsSelectStage(prompter, state); - // Finalize - await finalizeStage(prompter, state, configService); + // Finalize writes configs/assets/skills, but defer the success summary until + // after the gateway health/bootstrap gates complete. + const finalizeResult = await finalizeStage(prompter, state, configService, { + deferSummary: true, + }); // Gateway stages if (!options.skipGateway) { @@ -392,20 +402,25 @@ async function runHeadlessPath( if (!configResult.ready || !configResult.host || !configResult.port) { prompter.warn('Gateway configuration failed in headless mode — aborting wizard.'); process.exit(1); - } else { - const bootstrapResult = await gatewayBootstrapStage(prompter, state, { - host: configResult.host, - port: configResult.port, - }); - if (!bootstrapResult.completed) { - prompter.warn('Admin bootstrap failed — aborting wizard.'); - process.exit(1); - } + return; } + + const bootstrapResult = await gatewayBootstrapStage(prompter, state, { + host: configResult.host, + port: configResult.port, + }); + if (!bootstrapResult.completed) { + prompter.warn('Admin bootstrap failed — aborting wizard.'); + process.exit(1); + return; + } + finalizeResult.showSummary(); } catch (err) { prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`); throw err; } + } else { + finalizeResult.showSummary(); } } @@ -426,8 +441,11 @@ async function runKeepPath( // Skills await skillsSelectStage(prompter, state); - // Finalize - await finalizeStage(prompter, state, configService); + // Finalize writes configs/assets/skills, but defer the success summary until + // after the gateway health/bootstrap gates complete. + const finalizeResult = await finalizeStage(prompter, state, configService, { + deferSummary: true, + }); // Gateway stages if (!options.skipGateway) { @@ -447,11 +465,15 @@ async function runKeepPath( if (!bootstrapResult.completed) { prompter.warn('Admin bootstrap failed — aborting wizard.'); process.exit(1); + return; } + finalizeResult.showSummary(); } } catch (err) { prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`); throw err; } + } else { + finalizeResult.showSummary(); } } -- 2.54.0 From 495f73bfdb79a6c5956f2af486a7d72e7fbccf37 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Thu, 25 Jun 2026 18:44:35 +0000 Subject: [PATCH 09/13] fix(wizard): avoid rerunning completed setup steps (#692) --- docs/scratchpads/B4-wizard-step-dedup.md | 36 +++++++++ .../integration/unified-wizard.test.ts | 76 +++++++++++++++++++ .../mosaic/src/stages/gateway-config.spec.ts | 39 ++++++++++ packages/mosaic/src/stages/gateway-config.ts | 3 + packages/mosaic/src/stages/quick-start.ts | 4 +- packages/mosaic/src/wizard.ts | 30 ++++++-- 6 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 docs/scratchpads/B4-wizard-step-dedup.md diff --git a/docs/scratchpads/B4-wizard-step-dedup.md b/docs/scratchpads/B4-wizard-step-dedup.md new file mode 100644 index 00000000..cc8d4ce1 --- /dev/null +++ b/docs/scratchpads/B4-wizard-step-dedup.md @@ -0,0 +1,36 @@ +# B4 — Wizard step deduplication + +## Problem + +Greenfield wizard testing showed completed wizard steps could be executed again after the menu marked them `[done]`. In practice this made the Providers/API-key flow and Skills flow appear twice in one wizard run. + +There was a second related API-key duplication path: when the Providers step was completed with no key, `gatewayConfigStage` still prompted for `ANTHROPIC_API_KEY` during Finish because it only skipped the gateway API-key prompt when `providerKey` was non-empty. + +## Diagnosis + +- `runMenuLoop` labeled completed sections with `[done]`, but still dispatched the selected step again if the user selected that row. +- Quick Start ran Providers and Skills but did not mark those sections complete in `completedSections`. +- `runFinishPath`/`quickStartPath` defaulted `providerType` to `none` for gateway config, which made it impossible for `gatewayConfigStage` to distinguish: + - provider step completed and user intentionally skipped the key, vs. + - provider step was never run. + +## Fix + +- Added a shared menu section key helper and a completed-step guard in `runMenuLoop`. +- Completed menu steps now log a skip message instead of re-running their stage. +- Quick Start marks Providers and Skills complete after running them. +- Finish/Quick Start now pass `state.providerType` as-is to gateway config instead of defaulting to `none`. +- `gatewayConfigStage` treats `providerType: 'none'` as an explicit completed provider setup with no key and skips the second gateway API-key prompt. + +## Verification + +- Added unified wizard regression coverage asserting repeated Providers/Skills menu selections only execute each stage once. +- Added gateway config coverage asserting `providerType: 'none'` does not prompt for a gateway API key and writes no API key env var. +- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard gateway-config` +- `pnpm format:check` +- `pnpm typecheck` +- `pnpm lint` +- `pnpm build` +- `pnpm test` +- Codex code review: approve. +- Codex security review: no findings. diff --git a/packages/mosaic/__tests__/integration/unified-wizard.test.ts b/packages/mosaic/__tests__/integration/unified-wizard.test.ts index 5becc836..764f5b26 100644 --- a/packages/mosaic/__tests__/integration/unified-wizard.test.ts +++ b/packages/mosaic/__tests__/integration/unified-wizard.test.ts @@ -11,9 +11,37 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js'; import { createConfigService } from '../../src/config/config-service.js'; +import type { SelectOption } from '../../src/prompter/interface.js'; +import type { MenuSection, WizardState } from '../../src/types.js'; const gatewayConfigMock = vi.fn(); const gatewayBootstrapMock = vi.fn(); +const providerSetupMock = vi.fn(); +const skillsSelectMock = vi.fn(); + +class SequencedMenuPrompter extends HeadlessPrompter { + constructor( + answers: Record, + private readonly menuChoices: string[], + ) { + super(answers); + } + + override async select(opts: { + message: string; + options: SelectOption[]; + initialValue?: T; + }): Promise { + if (opts.message === 'What would you like to configure?') { + const next = this.menuChoices.shift(); + if (!next) throw new Error('No queued menu choice left'); + const match = opts.options.find((o) => String(o.value) === next); + if (!match) throw new Error(`Queued menu choice not available: ${next}`); + return match.value; + } + return super.select(opts); + } +} vi.mock('../../src/stages/gateway-config.js', () => ({ gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args), @@ -23,6 +51,14 @@ vi.mock('../../src/stages/gateway-bootstrap.js', () => ({ gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args), })); +vi.mock('../../src/stages/provider-setup.js', () => ({ + providerSetupStage: (...args: unknown[]) => providerSetupMock(...args), +})); + +vi.mock('../../src/stages/skills-select.js', () => ({ + skillsSelectStage: (...args: unknown[]) => skillsSelectMock(...args), +})); + // Import AFTER the mocks so runWizard picks up the mocked stage modules. import { runWizard } from '../../src/wizard.js'; @@ -44,6 +80,16 @@ describe('Unified wizard (runWizard with default skipGateway)', () => { } gatewayConfigMock.mockReset(); gatewayBootstrapMock.mockReset(); + providerSetupMock.mockReset(); + skillsSelectMock.mockReset(); + providerSetupMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => { + state.providerType = 'none'; + state.completedSections?.add('providers' satisfies MenuSection); + }); + skillsSelectMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => { + state.selectedSkills = []; + state.completedSections?.add('skills' satisfies MenuSection); + }); // Pretend we're on an interactive TTY so the wizard's headless-abort // branch does not call `process.exit(1)` during these tests. Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); @@ -184,4 +230,34 @@ describe('Unified wizard (runWizard with default skipGateway)', () => { expect(gatewayConfigMock).not.toHaveBeenCalled(); expect(gatewayBootstrapMock).not.toHaveBeenCalled(); }); + + it('does not re-run completed provider or skills menu steps', async () => { + const prompter = new SequencedMenuPrompter( + { + 'What name should agents use?': 'TestBot', + 'Communication style': 'direct', + 'Your name': 'Tester', + 'Your pronouns': 'They/Them', + 'Your timezone': 'UTC', + }, + ['providers', 'providers', 'skills', 'skills', 'finish'], + ); + + await runWizard({ + mosaicHome: tmpDir, + sourceDir: tmpDir, + prompter, + configService: createConfigService(tmpDir, tmpDir), + skipGateway: true, + }); + + expect(providerSetupMock).toHaveBeenCalledTimes(1); + expect(skillsSelectMock).toHaveBeenCalledTimes(1); + expect(prompter.getLogs()).toEqual( + expect.arrayContaining([ + expect.stringContaining('Providers [done] is already complete; skipping.'), + expect.stringContaining('Skills [done] is already complete; skipping.'), + ]), + ); + }); }); diff --git a/packages/mosaic/src/stages/gateway-config.spec.ts b/packages/mosaic/src/stages/gateway-config.spec.ts index fe71e489..52554acc 100644 --- a/packages/mosaic/src/stages/gateway-config.spec.ts +++ b/packages/mosaic/src/stages/gateway-config.spec.ts @@ -167,6 +167,45 @@ describe('gatewayConfigStage', () => { expect(state.gateway?.regeneratedConfig).toBe(true); }); + it('does not ask for a gateway API key when provider setup was completed with no key', async () => { + delete process.env['MOSAIC_ASSUME_YES']; + const originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + try { + const textFn = vi.fn(async (opts: { message: string; initialValue?: string }) => { + if (opts.message === 'Gateway port') return opts.initialValue ?? '14242'; + if (opts.message === 'Web UI hostname (for browser access)') return 'localhost'; + if (opts.message.includes('API_KEY')) { + throw new Error('gateway API key prompt should be skipped'); + } + return ''; + }); + const p = buildPrompter({ text: textFn, select: vi.fn().mockResolvedValue('local') }); + const state = makeState('/home/user/.config/mosaic'); + + const result = await gatewayConfigStage(p, state, { + host: 'localhost', + defaultPort: 14242, + skipInstall: true, + providerType: 'none', + }); + + expect(result.ready).toBe(true); + expect(textFn).not.toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('API_KEY') }), + ); + const envContents = readFileSync(daemonState.envFile, 'utf-8'); + expect(envContents).not.toContain('ANTHROPIC_API_KEY='); + expect(envContents).not.toContain('OPENAI_API_KEY='); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + } + }); + it('short-circuits when gateway is already fully installed and user declines rerun', async () => { // Pre-populate both files + running daemon + meta with token const fs = require('node:fs'); diff --git a/packages/mosaic/src/stages/gateway-config.ts b/packages/mosaic/src/stages/gateway-config.ts index fdfd8baa..b6abde39 100644 --- a/packages/mosaic/src/stages/gateway-config.ts +++ b/packages/mosaic/src/stages/gateway-config.ts @@ -506,6 +506,9 @@ async function collectAndWriteConfig( if (opts.providerKey) { anthropicKey = opts.providerKey; p.log(`Using API key from provider setup (${opts.providerType ?? 'unknown'}).`); + } else if (opts.providerType === 'none') { + anthropicKey = ''; + p.log('No API key provided during provider setup; skipping gateway API key prompt.'); } else { anthropicKey = await p.text({ message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)', diff --git a/packages/mosaic/src/stages/quick-start.ts b/packages/mosaic/src/stages/quick-start.ts index f132cc4a..aa2d63ea 100644 --- a/packages/mosaic/src/stages/quick-start.ts +++ b/packages/mosaic/src/stages/quick-start.ts @@ -37,6 +37,7 @@ export async function quickStartPath( // 1. Provider setup (first question) await providerSetupStage(prompter, state); + state.completedSections?.add('providers'); // Apply sensible defaults for everything else state.soul.agentName ??= 'Mosaic'; @@ -57,6 +58,7 @@ export async function quickStartPath( // Skills (recommended set, no user input in quick mode) await skillsSelectStage(prompter, state); + state.completedSections?.add('skills'); // Finalize writes configs/assets/skills, but defer the success summary until // after the gateway health/bootstrap gates complete. @@ -75,7 +77,7 @@ export async function quickStartPath( portOverride: options.gatewayPortOverride, skipInstall: options.skipGatewayNpmInstall, providerKey: state.providerKey, - providerType: state.providerType ?? 'none', + providerType: state.providerType, }); if (!configResult.ready || !configResult.host || !configResult.port) { diff --git a/packages/mosaic/src/wizard.ts b/packages/mosaic/src/wizard.ts index 757aeeec..571b7248 100644 --- a/packages/mosaic/src/wizard.ts +++ b/packages/mosaic/src/wizard.ts @@ -126,6 +126,11 @@ type MenuChoice = | 'advanced' | 'finish'; +function menuSectionKey(section: MenuChoice): MenuSection | null { + if (section === 'quick-start' || section === 'finish') return null; + return section === 'gateway-config' ? 'gateway' : section; +} + function menuLabel(section: MenuChoice, completed: Set): string { const labels: Record = { 'quick-start': 'Quick Start', @@ -137,14 +142,24 @@ function menuLabel(section: MenuChoice, completed: Set): string { finish: 'Finish & Apply', }; const base = labels[section]; - const sectionKey: MenuSection = - section === 'gateway-config' ? 'gateway' : (section as MenuSection); - if (completed.has(sectionKey)) { + const sectionKey = menuSectionKey(section); + if (sectionKey && completed.has(sectionKey)) { return `${base} [done]`; } return base; } +function skipCompletedMenuChoice( + prompter: WizardPrompter, + completed: Set, + choice: MenuChoice, +): boolean { + const sectionKey = menuSectionKey(choice); + if (!sectionKey || !completed.has(sectionKey)) return false; + prompter.log(`${menuLabel(choice, completed)} is already complete; skipping.`); + return true; +} + async function runMenuLoop( prompter: WizardPrompter, state: WizardState, @@ -201,21 +216,25 @@ async function runMenuLoop( return; // Quick start is a complete flow — exit menu case 'providers': + if (skipCompletedMenuChoice(prompter, completed, choice)) break; await providerSetupStage(prompter, state); completed.add('providers'); break; case 'identity': + if (skipCompletedMenuChoice(prompter, completed, choice)) break; await agentIntentStage(prompter, state); completed.add('identity'); break; case 'skills': + if (skipCompletedMenuChoice(prompter, completed, choice)) break; await skillsSelectStage(prompter, state); completed.add('skills'); break; case 'gateway-config': + if (skipCompletedMenuChoice(prompter, completed, choice)) break; // Gateway config is handled during Finish — mark as "configured" // after user reviews settings. await runGatewaySubMenu(prompter, state, options); @@ -223,6 +242,7 @@ async function runMenuLoop( break; case 'advanced': + if (skipCompletedMenuChoice(prompter, completed, choice)) break; await runAdvancedSubMenu(prompter, state); completed.add('advanced'); break; @@ -325,7 +345,7 @@ async function runFinishPath( portOverride: options.gatewayPortOverride, skipInstall: options.skipGatewayNpmInstall, providerKey: state.providerKey, - providerType: state.providerType ?? 'none', + providerType: state.providerType, }); if (configResult.ready && configResult.host && configResult.port) { @@ -396,7 +416,7 @@ async function runHeadlessPath( portOverride: options.gatewayPortOverride, skipInstall: options.skipGatewayNpmInstall, providerKey: state.providerKey, - providerType: state.providerType ?? 'none', + providerType: state.providerType, }); if (!configResult.ready || !configResult.host || !configResult.port) { -- 2.54.0 From 193331544d46b2581d221e5a4ee07ec9c5ef2a7c Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Fri, 10 Jul 2026 01:30:10 +0000 Subject: [PATCH 10/13] =?UTF-8?q?fix(wizard):=20honor=20MOSAIC=5FGATEWAY?= =?UTF-8?q?=5FSKIP=5FNPM=5FINSTALL=20=E2=80=94=20unblock=20install.sh=20--?= =?UTF-8?q?dev=20gateway=20testing=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mosaic/src/stages/gateway-config.spec.ts | 31 +++++++++++++++++++ packages/mosaic/src/stages/gateway-config.ts | 7 ++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/stages/gateway-config.spec.ts b/packages/mosaic/src/stages/gateway-config.spec.ts index 52554acc..befc5d21 100644 --- a/packages/mosaic/src/stages/gateway-config.spec.ts +++ b/packages/mosaic/src/stages/gateway-config.spec.ts @@ -136,6 +136,7 @@ describe('gatewayConfigStage', () => { delete process.env['MOSAIC_STORAGE_TIER']; delete process.env['MOSAIC_DATABASE_URL']; delete process.env['MOSAIC_VALKEY_URL']; + delete process.env['MOSAIC_GATEWAY_SKIP_NPM_INSTALL']; }); afterEach(() => { @@ -167,6 +168,36 @@ describe('gatewayConfigStage', () => { expect(state.gateway?.regeneratedConfig).toBe(true); }); + it('installs the gateway package on fresh install when skipInstall is not set', async () => { + const p = buildPrompter(); + const state = makeState('/home/user/.config/mosaic'); + + const result = await gatewayConfigStage(p, state, { + host: 'localhost', + defaultPort: 14242, + skipInstall: false, + }); + + expect(result.ready).toBe(true); + expect(daemonState.installPkgCalled).toBe(1); + }); + + it('honors MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 and skips the registry install (dev/offline installs)', async () => { + process.env['MOSAIC_GATEWAY_SKIP_NPM_INSTALL'] = '1'; + const p = buildPrompter(); + const state = makeState('/home/user/.config/mosaic'); + + const result = await gatewayConfigStage(p, state, { + host: 'localhost', + defaultPort: 14242, + skipInstall: false, + }); + + // The source-built global gateway must NOT be overwritten by @latest. + expect(result.ready).toBe(true); + expect(daemonState.installPkgCalled).toBe(0); + }); + it('does not ask for a gateway API key when provider setup was completed with no key', async () => { delete process.env['MOSAIC_ASSUME_YES']; const originalIsTTY = process.stdin.isTTY; diff --git a/packages/mosaic/src/stages/gateway-config.ts b/packages/mosaic/src/stages/gateway-config.ts index b6abde39..38bd85d9 100644 --- a/packages/mosaic/src/stages/gateway-config.ts +++ b/packages/mosaic/src/stages/gateway-config.ts @@ -294,7 +294,12 @@ export async function gatewayConfigStage( } // Install the gateway npm package on first install or after failure. - if (!opts.skipInstall && !daemonRunning) { + // MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 forces a skip even without opts.skipInstall: + // used by dev/offline installs where @mosaicstack/gateway is already present + // globally (e.g. a build-from-source `install.sh --dev`) and must not be + // overwritten by the registry @latest build. + const skipNpmInstall = opts.skipInstall || process.env['MOSAIC_GATEWAY_SKIP_NPM_INSTALL'] === '1'; + if (!skipNpmInstall && !daemonRunning) { installGatewayPackage(); } -- 2.54.0 From b8844e1ff0e8242885361f9aba3516a36a6ad2f8 Mon Sep 17 00:00:00 2001 From: coder-mos2 Date: Sun, 2 Aug 2026 23:16:09 -0500 Subject: [PATCH 11/13] fix(sync): close local queue semantic merge gap --- .../commands/command-executor-p8012.spec.ts | 20 ++++++++++++++-- .../gateway/src/gc/session-gc.service.spec.ts | 13 +++++++++++ .../system-override.service.spec.ts | 23 +++++++++++++++++++ apps/gateway/src/queue/queue.service.spec.ts | 1 + apps/gateway/src/queue/queue.service.ts | 7 ++++++ 5 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 apps/gateway/src/preferences/system-override.service.spec.ts diff --git a/apps/gateway/src/commands/command-executor-p8012.spec.ts b/apps/gateway/src/commands/command-executor-p8012.spec.ts index fc5d9660..b22ed05a 100644 --- a/apps/gateway/src/commands/command-executor-p8012.spec.ts +++ b/apps/gateway/src/commands/command-executor-p8012.spec.ts @@ -72,13 +72,13 @@ const mockChatGateway = { broadcastSessionInfo: vi.fn(), }; -function buildService(): CommandExecutorService { +function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService { return new CommandExecutorService( mockRegistry as never, mockAgentService as never, mockSystemOverride as never, mockSessionGC as never, - mockRedis as never, + redis as never, mockBrain as never, null, mockChatGateway as never, @@ -131,6 +131,22 @@ describe('CommandExecutorService — P8-012 commands', () => { 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 it('/provider with no args returns usage message', async () => { const payload: SlashCommandPayload = { command: 'provider', conversationId }; diff --git a/apps/gateway/src/gc/session-gc.service.spec.ts b/apps/gateway/src/gc/session-gc.service.spec.ts index 8c5ab6ad..014df200 100644 --- a/apps/gateway/src/gc/session-gc.service.spec.ts +++ b/apps/gateway/src/gc/session-gc.service.spec.ts @@ -119,6 +119,19 @@ describe('SessionGCService', () => { ).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 () => { const result = await service.collect('test-session-id'); expect(result.sessionId).toBe('test-session-id'); diff --git a/apps/gateway/src/preferences/system-override.service.spec.ts b/apps/gateway/src/preferences/system-override.service.spec.ts new file mode 100644 index 00000000..8d65080a --- /dev/null +++ b/apps/gateway/src/preferences/system-override.service.spec.ts @@ -0,0 +1,23 @@ +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'); + }); +}); diff --git a/apps/gateway/src/queue/queue.service.spec.ts b/apps/gateway/src/queue/queue.service.spec.ts index 85f1e641..94e86a02 100644 --- a/apps/gateway/src/queue/queue.service.spec.ts +++ b/apps/gateway/src/queue/queue.service.spec.ts @@ -17,6 +17,7 @@ describe('QueueService local tier', () => { 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({ diff --git a/apps/gateway/src/queue/queue.service.ts b/apps/gateway/src/queue/queue.service.ts index a3a8162e..76f6268c 100644 --- a/apps/gateway/src/queue/queue.service.ts +++ b/apps/gateway/src/queue/queue.service.ts @@ -199,7 +199,14 @@ export class QueueService implements OnModuleInit, OnModuleDestroy { * safe retirement of previously registered system-wide jobs. */ async removeRepeatableJobs(queueName: string, jobName: string): Promise { + 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); + if (!queue) return 0; const jobs = await queue.getRepeatableJobs(); const matchingJobs = jobs.filter((job) => job.name === jobName); await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key))); -- 2.54.0 From 033bb7aa8757884744e43b26afddb588cc5ad3c4 Mon Sep 17 00:00:00 2001 From: be-coder-05 Date: Wed, 5 Aug 2026 10:31:25 -0500 Subject: [PATCH 12/13] test(installer): preregister P0-P9 greenfield RED --- docs/PRD.md | 33 ++ .../1050-install-state-machine-red-fixture.md | 62 +++ tools/e2e-install-test.sh | 417 +++++++++++------- tools/install-state-machine.test.sh | 125 ++++++ 4 files changed, 474 insertions(+), 163 deletions(-) create mode 100644 docs/scratchpads/1050-install-state-machine-red-fixture.md create mode 100755 tools/install-state-machine.test.sh diff --git a/docs/PRD.md b/docs/PRD.md index 77ccd609..e3e8c606 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1368,3 +1368,36 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release. 10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities. 11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management. + +--- + +## Greenfield install correctness — C1 (#1050) + +### Problem and objective + +A from-zero install can report success while leaving the target host unusable because the installer has no transactional state machine capable of certifying its own postconditions. C1 supplies the structural spine and red-first fixture; later cards repair the individual failed postconditions. + +### Normative requirements + +1. The installer SHALL implement the canonical P0–P9 numbering from the greenfield-install PRD v2: P0 Resolve context; P1 Preflight; P2 Acquire artifacts; P3 Install CLI; P4 Install framework + skills; P5 Identity; P6 Runtime linking / activation; P7 Services; P8 Shell discoverability; P9 Verify + commit. +2. Every phase SHALL declare preconditions, action, committed postconditions, and rollback. An unverifiable postcondition SHALL fail the install non-zero with the named phase and a remediation line; no best-effort failure may still certify success. P1's required-tool closure includes tools invoked by later phases, including `git`; a downstream prerequisite may not remain undeclared and degrade silently. +3. A durable mutation journal SHALL open before the first mutation and commit at P9. Fallible command output needed to diagnose a phase SHALL be journaled and surfaced, never discarded. +4. `--check` SHALL run exactly the P0–P8 postcondition predicates without mutation, report each phase PASS/FAIL, and exit non-zero if any predicate fails. +5. P4 SHALL consume a checkout-free, lane/versioned shipped-set declaration published by the installer. C1 SHALL NOT select among the currently disagreeing framework-payload, repository-root, sync-source, and W-jarvis populations; while no declaration exists, P4 reports `NOT-MEASURED / UNDECLARED` and remains blocking rather than fabricating a count. C5 owns the declaration's contents and containment/loadability fulfillment. +6. The from-zero fixture SHALL be lane-parametric, use Debian/glibc, run the documented install command as a non-root target user with an isolated HOME, and inherit no host credentials, npm cache, home directory, or runtime configuration. +7. The fixture SHALL select `next` with `--next` or `MOSAIC_NEXT=1` and assert the resolved lane version. Internal predicates use P3's absolute CLI path; shell discoverability is tested only at P8. +8. Fault injection after each P2–P8 phase SHALL prove either clean rollback or a durable, honestly reported resumable partial state, with no journal incorrectly left in progress. +9. Unsupported musl/Alpine and unavailable Docker SHALL fail loudly rather than skip as pass. + +### C1 acceptance criteria + +1. The pre-C1 from-zero matrix records both discriminating controls: with `git` absent, the legacy installer still exits zero while P1 fails and skill sync degrades; with `git` present, P1 passes and the observed sync store/runtime links are 101/101. The C1 installer must fail at P1 before mutation when `git` is absent. +2. The discriminating P3 row passes: the binary exists at the expected absolute path and reports exactly the resolved `next` lane version, while P4, P5, and P8 fail. +3. The `--check` mutation negative control proves host fingerprints are byte-identical before and after observation. +4. Woodpecker executes and validates the expected RED fixture; C1 does not repair P4/P5/P8 or activate #869. + +### Explicit exclusions and dependencies + +- C2 owns P8/PATH, C3 owns P5/headless identity, C4 owns P6 activation policy, and C5 owns P4/skills. +- Main-lane execution is a promotion precondition owned by #1037; C1 only makes the fixture lane-parametric. +- RM-02 and #869 activation are out of scope. diff --git a/docs/scratchpads/1050-install-state-machine-red-fixture.md b/docs/scratchpads/1050-install-state-machine-red-fixture.md new file mode 100644 index 00000000..33f42fe9 --- /dev/null +++ b/docs/scratchpads/1050-install-state-machine-red-fixture.md @@ -0,0 +1,62 @@ +# #1050 — Installer P0–P9 state machine and red-first fixture + +## Objective + +Implement C1 from the canonical greenfield-install PRD v2: a transactional P0–P9 installer spine, a side-effect-free P0–P8 `--check`, and a lane-parametric Debian/glibc non-root from-zero fixture. The acceptance milestone is an attributable RED on the pre-C1 installer while preserving P3 PASS. + +## Authority and scope + +- Canonical requirements: `jason.woltje/jarvis-brain` `docs/plans/2026-08-04-greenfield-install-blockers-PRD-v2.md`, read from local `origin/main` object `b2b6ed41f5aff5ea964e69b7c701cb45718742fa`; remote currency is **unestablished** because authenticated fetch returned repository-not-found. +- Tracking: `mosaicstack/stack#1050` on `git.mosaicstack.dev` (author read back as `be-coder-05`). +- Base: `origin/next` `4df478cdd150fdf8d52ea109f02ade5d85017acd`. +- Out of scope: PATH, skills, headless wizard/identity, activation remediation, #869 wiring, RM-02, main promotion. +- `docs/TASKS.md` is orchestrator-single-writer and is not modified by this worker. + +## Plan + +1. Pre-register the canonical phase/output/side-effect-free/fault-injection checks and observe RED against the base installer. +2. Commit the immutable red-first acceptance fixture before implementation. +3. Add the state-machine/journal/postcondition spine without repairing P4/P5/P8 symptoms. +4. Wire the expected-RED from-zero fixture into Woodpecker using Debian/glibc and a non-root target user. +5. Run shell/static baselines, situational container validation, code review, security review, then deliver through a PR to `next` under the coordinator-owned merge path. + +## Budget + +- Working estimate: 32K reasoning/output tokens. +- Hard external cap: none stated. +- Adaptation: keep implementation in shell surfaces already in scope; no package dependency install unless repository gates require it. + +## Pre-registered acceptance checks + +| ID | Exact case | Expected pre-fix result | +|---|---|---| +| C1-R1 | `tools/e2e-install-test.sh --lane next` in a clean Debian 12 container as uid 1001 | non-zero; P3 PASS; P4 `NOT-MEASURED / UNDECLARED`; P5/P6/P8 FAIL with own reasons | +| C1-R2 | `tools/install-state-machine.test.sh` phase table case | RED because base installer does not enumerate canonical P0–P9 contracts | +| C1-R3 | side-effect-free `--check` case over a fingerprinted HOME | RED because base `--check` is version-only rather than P0–P8 predicates | +| C1-R4 | fault injection after each P2…P8 | RED because base installer has no injectable durable journal/rollback state | +| C1-R5 | Docker unavailable | base harness incorrectly exits 0; replacement must fail non-zero | +| C1-R6 | lane resolution | bare checkout is forbidden; fixture must pass `--next` and assert the resolved prerelease version | +| C1-R7 | same Debian fixture with `git` absent vs present | absent: P1 FAIL while legacy installer exits 0 and sync degrades; present: P1 PASS and observed store/runtime containment 101/101 | + +## Progress + +- [x] Charter, doctrine, delivery/CI/QA/docs guides read. +- [x] Canonical PRD v2 and charters read from local origin object; numbering reconciles with the TL spec. No numbering conflict found. TL additions (early durable journal and INV-C) are additive, not contradictory. +- [x] Target base reachability verified with `merge-base --is-ancestor`. +- [x] Issue #1050 created and provider author read back. +- [x] Initial RED captured; TL rejected P4's repo-root count as a false RED. Four populations disagree (framework payload 1, repo root 13, sync store 101 in the fixture, W-jarvis observation 7), so C1 now requires a checkout-free declared shipped-set artifact and reports P4 `NOT-MEASURED / UNDECLARED` until C5 supplies it. +- [x] P6 strengthens #869: the two dead enforcement hooks reproduce from zero on a clean broker-less container. C1 asserts the breach but neither wires nor unwires it. +- [x] P1 false pass identified from the P4 evidence row: `git` is absent from the Debian base and was undeclared even though skill sync shells out to it. C1 adds `git` to P1; the fixture matrix preserves absent/present controls. The prior claim that web1's missing runtime skills reproduce this greenfield mechanism is withdrawn by the TL and is not carried here. +- [ ] Corrected RED transcript captured and reported. +- [ ] State machine implemented. +- [ ] Reviews complete. + +## Risks / blockers + +- The deployed create wrappers do not expose `--dry-run`; identity preflight was performed through `pr-merge.sh --dry-run` on the same HOMELAB repo, which resolved `git.mosaicstack.dev` + `be-coder-05`. The issue create then fell back from tea to the API but provider read-back confirmed author `be-coder-05`. +- `next` is an integration lane; `main` promotion remains #1037-owned. +- #869 must remain staged and inactive. + +## Verification log + +(To be updated with exact commands and resulting objects.) diff --git a/tools/e2e-install-test.sh b/tools/e2e-install-test.sh index 672103b2..9f64eca8 100755 --- a/tools/e2e-install-test.sh +++ b/tools/e2e-install-test.sh @@ -1,184 +1,275 @@ #!/usr/bin/env bash -# ─── Mosaic Stack — End-to-End Install Test ──────────────────────────────────── +# Greenfield installer acceptance fixture. # -# Runs a clean-container install test to verify the full first-run flow: -# tools/install.sh -> mosaic wizard (non-interactive) -# -> mosaic gateway install -# -> mosaic gateway verify -# -# Usage: -# bash tools/e2e-install-test.sh -# -# Requirements: -# - Docker (skips gracefully if not available) -# - Run from the repository root -# -# How it works: -# 1. Mounts the repository into a node:22-alpine container. -# 2. Installs prerequisites (bash, curl, jq, git) inside the container. -# 3. Runs `bash tools/install.sh --yes --no-auto-launch` to install the -# framework and CLI from the Gitea registry. -# 4. Runs `mosaic wizard --non-interactive` to set up SOUL/USER. -# 5. Runs `mosaic gateway install` with piped defaults (non-interactive). -# 6. Runs `mosaic gateway verify` and checks its exit code. -# NOTE: `mosaic gateway verify` is a new command added in the -# feat/mosaic-first-run-ux branch. If the installed CLI version -# pre-dates this branch (does not have `gateway verify`), the test -# marks this step as EXPECTED-SKIP and reports the installed version. -# 7. Reports PASS or FAIL with a summary. -# -# To run manually: -# cd /path/to/mosaic-stack -# bash tools/e2e-install-test.sh -# -# ────────────────────────────────────────────────────────────────────────────── +# The fixture itself is intentionally RED until the C2-C5 phase owners repair +# their postconditions. C1's CI gate executes it and validates that the RED is +# attributable (including the discriminating P3 PASS); it does not turn the +# failed install into a false green. set -euo pipefail -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -IMAGE="node:22-alpine" -CONTAINER_NAME="mosaic-e2e-install-$$" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LANE="${MOSAIC_INSTALL_LANE:-next}" +SOURCE="${MOSAIC_INSTALL_SOURCE:-checkout}" +IMAGE="${MOSAIC_INSTALL_IMAGE:-node:22-bookworm-slim}" +GIT_MODE="${MOSAIC_INSTALL_GIT_MODE:-present}" +INSTALLER_FILE="${MOSAIC_FIXTURE_INSTALLER_FILE:-$ROOT/tools/install.sh}" -# ─── Colour helpers ─────────────────────────────────────────────────────────── -if [[ -t 1 ]]; then - R=$'\033[0;31m' G=$'\033[0;32m' Y=$'\033[0;33m' BOLD=$'\033[1m' RESET=$'\033[0m' -else - R="" G="" Y="" BOLD="" RESET="" -fi +usage() { + cat <<'EOF' +Usage: tools/e2e-install-test.sh [--lane next|main] [--source checkout|remote] [--git present|absent] -info() { echo "${BOLD}[e2e]${RESET} $*"; } -ok() { echo "${G}[PASS]${RESET} $*"; } -fail() { echo "${R}[FAIL]${RESET} $*" >&2; } -warn() { echo "${Y}[WARN]${RESET} $*"; } - -# ─── Docker availability check ──────────────────────────────────────────────── -if ! command -v docker &>/dev/null; then - warn "Docker not found — skipping e2e install test." - warn "Install Docker and re-run this script to exercise the full install flow." - exit 0 -fi - -if ! docker info &>/dev/null 2>&1; then - warn "Docker daemon is not running or not accessible — skipping e2e install test." - exit 0 -fi - -info "Docker available — proceeding with e2e install test." -info "Repo root: ${REPO_ROOT}" -info "Container image: ${IMAGE}" - -# ─── Inline script that runs INSIDE the container ──────────────────────────── -INNER_SCRIPT="$(mktemp /tmp/mosaic-e2e-inner-XXXXXX.sh)" -trap 'rm -f "$INNER_SCRIPT"' EXIT - -cat > "$INNER_SCRIPT" <<'INNER_SCRIPT_EOF' -#!/bin/sh -# Bootstrap: /bin/sh until bash is installed, then re-exec. -set -e - -echo "=== [inner] Installing system prerequisites ===" -apk add --no-cache bash curl jq git 2>/dev/null || \ - apt-get install -y -q bash curl jq git 2>/dev/null || true - -# Re-exec under bash. -if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1; then - exec bash "$0" "$@" -fi - -# ── bash from here ──────────────────────────────────────────────────────────── -set -euo pipefail - -echo "=== [inner] Node.js / npm versions ===" -node --version -npm --version - -echo "=== [inner] Setting up npm global prefix ===" -export NPM_PREFIX="/root/.npm-global" -mkdir -p "$NPM_PREFIX/bin" -npm config set prefix "$NPM_PREFIX" 2>/dev/null || true -export PATH="$NPM_PREFIX/bin:$PATH" - -echo "=== [inner] Running install.sh --yes --no-auto-launch ===" -# Install both framework and CLI from the Gitea registry. -MOSAIC_SKIP_SKILLS_SYNC=1 \ -MOSAIC_ASSUME_YES=1 \ - bash /repo/tools/install.sh --yes --no-auto-launch - -INSTALLED_VERSION="$(mosaic --version 2>/dev/null || echo 'unknown')" -echo "[inner] mosaic CLI installed: ${INSTALLED_VERSION}" - -echo "=== [inner] Running mosaic wizard (non-interactive) ===" -mosaic wizard \ - --non-interactive \ - --name "test-agent" \ - --user-name "tester" \ - --pronouns "they/them" \ - --timezone "UTC" || { - echo "[WARN] mosaic wizard exited non-zero — continuing" +Runs the documented installer command from zero in Debian/glibc as a non-root +uid with an isolated HOME. The fixture exits non-zero when any P0-P8 +postcondition fails. `next` is always selected with the --next installer flag. +EOF } -echo "=== [inner] Running mosaic gateway install ===" -# Feed non-interactive answers: -# "1" → storage tier: local -# "" → port: accept default (14242) -# "" → ANTHROPIC_API_KEY: skip -# "" → CORS origin: accept default -# Then admin bootstrap: name, email, password -printf '1\n\n\n\nTest Admin\ntest@example.com\ntestpassword123\n' \ - | mosaic gateway install -INSTALL_EXIT="$?" -if [ "${INSTALL_EXIT}" -ne 0 ]; then - echo "[ERR] mosaic gateway install exited ${INSTALL_EXIT}" - mosaic gateway status 2>/dev/null || true - exit "${INSTALL_EXIT}" +while [[ $# -gt 0 ]]; do + case "$1" in + --lane) LANE="${2:-}"; shift 2 ;; + --source) SOURCE="${2:-}"; shift 2 ;; + --git) GIT_MODE="${2:-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "[fixture] unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +case "$LANE" in next|main) ;; *) echo "[fixture] unsupported lane '$LANE' (expected next|main)" >&2; exit 2 ;; esac +case "$SOURCE" in checkout|remote) ;; *) echo "[fixture] unsupported source '$SOURCE' (expected checkout|remote)" >&2; exit 2 ;; esac +case "$GIT_MODE" in present|absent) ;; *) echo "[fixture] unsupported git mode '$GIT_MODE' (expected present|absent)" >&2; exit 2 ;; esac + +if ! command -v docker >/dev/null 2>&1; then + echo "[fixture] FAIL: Docker is required; greenfield validation was NOT RUN." >&2 + exit 2 +fi +if ! docker info >/dev/null 2>&1; then + echo "[fixture] FAIL: Docker daemon is unavailable; greenfield validation was NOT RUN." >&2 + exit 2 fi -echo "=== [inner] Running mosaic gateway verify ===" -# `gateway verify` was added in feat/mosaic-first-run-ux. -# If the installed version pre-dates this, skip gracefully. -if ! mosaic gateway --help 2>&1 | grep -q 'verify'; then - echo "[SKIP] 'mosaic gateway verify' not available in installed version ${INSTALLED_VERSION}." - echo "[SKIP] This command was added in the feat/mosaic-first-run-ux release." - echo "[SKIP] Re-run after the new version is published to validate this step." - # Treat as pass — the install flow itself worked. - exit 0 +installer_b64="" +framework_payload_count="NOT-MEASURED" +repo_root_count="NOT-MEASURED" +if [[ "$SOURCE" == "checkout" ]]; then + installer_b64="$(base64 -w0 "$INSTALLER_FILE")" + [[ -d "$ROOT/packages/mosaic/framework/skills" ]] \ + && framework_payload_count="$(find "$ROOT/packages/mosaic/framework/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" + [[ -d "$ROOT/skills" ]] \ + && repo_root_count="$(find "$ROOT/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" fi -mosaic gateway verify -VERIFY_EXIT="$?" -echo "=== [inner] verify exit code: ${VERIFY_EXIT} ===" -exit "${VERIFY_EXIT}" -INNER_SCRIPT_EOF +inner="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-inner.XXXXXX.sh")" +trap 'rm -f "$inner"' EXIT +cat > "$inner" <<'INNER' +#!/usr/bin/env bash +set -euo pipefail -chmod +x "$INNER_SCRIPT" +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +packages=(bash ca-certificates curl jq passwd util-linux) +[[ "$FIXTURE_GIT_MODE" == "present" ]] && packages+=(git) +apt-get install -y -qq "${packages[@]}" >/dev/null -# ─── Pull image ─────────────────────────────────────────────────────────────── -info "Pulling ${IMAGE}…" -docker pull "${IMAGE}" --quiet +useradd --create-home --uid 1001 --shell /bin/bash mosaic +install -d -o mosaic -g mosaic /home/mosaic/work -# ─── Run container ──────────────────────────────────────────────────────────── -info "Starting container ${CONTAINER_NAME}…" +case "$FIXTURE_SOURCE" in + checkout) + printf '%s' "$FIXTURE_INSTALLER_B64" | base64 -d > /tmp/install.sh + ;; + remote) + curl -fsSL "https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/${FIXTURE_LANE}/tools/install.sh" > /tmp/install.sh + ;; +esac +chmod 0755 /tmp/install.sh +sha256sum /tmp/install.sh | sed 's/^/[fixture] installer sha256: /' -EXIT_CODE=0 -docker run --rm \ - --name "${CONTAINER_NAME}" \ - --volume "${REPO_ROOT}:/repo:ro" \ - --volume "${INNER_SCRIPT}:/e2e-inner.sh:ro" \ - --network host \ - "${IMAGE}" \ - /bin/sh /e2e-inner.sh \ - || EXIT_CODE=$? +cat > /tmp/run-as-target.sh <<'TARGET' +#!/usr/bin/env bash +set -uo pipefail -# ─── Report ─────────────────────────────────────────────────────────────────── -echo "" -if [[ "$EXIT_CODE" -eq 0 ]]; then - ok "End-to-end install test PASSED (exit ${EXIT_CODE})" +lane="$FIXTURE_LANE" +home="$HOME" +prefix="$home/.npm-global" +mosaic_home="$home/.config/mosaic" +install_log="$home/install.log" +failures=0 + +phase_pass() { printf '[%s] PASS: %s\n' "$1" "$2"; } +phase_fail() { printf '[%s] FAIL: %s\n' "$1" "$2"; failures=$((failures + 1)); } + +lane_args=() +resolved_spec='@mosaicstack/mosaic' +if [[ "$lane" == "next" ]]; then + lane_args+=(--next) + resolved_spec='@mosaicstack/mosaic@next' +fi + +resolved_version="$(npm view "$resolved_spec" version --registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ 2>/dev/null || true)" +printf '[fixture] resolved lane=%s package=%s version=%s\n' "$lane" "$resolved_spec" "${resolved_version:-UNRESOLVED}" + +set +e +MOSAIC_NO_COLOR=1 MOSAIC_ASSUME_YES=1 \ + bash /tmp/install.sh "${lane_args[@]}" --yes --no-auto-launch >"$install_log" 2>&1 +install_status=$? +set -e +cat "$install_log" +printf '[fixture] installer_exit=%d done_claims=%s\n' \ + "$install_status" "$(grep -cF 'Done.' "$install_log" || true)" + +# P0 Resolve context +shell="$(getent passwd "$(id -u)" | cut -d: -f7)" +if [[ "$(id -u)" -ne 0 && "$home" == "/home/mosaic" && "$shell" == "/bin/bash" ]] \ + && ldd --version 2>&1 | grep -qi 'glibc\|gnu libc' \ + && [[ "$(node -p 'Number(process.versions.node.split(".")[0])')" -ge 20 ]]; then + phase_pass P0 "target=mosaic uid=$(id -u) HOME=$home shell=$shell libc=glibc node=$(node --version)" else - fail "End-to-end install test FAILED (exit ${EXIT_CODE})" - echo "" - echo " Troubleshooting:" - echo " - Review the output above for the failing step." - echo " - Re-run with bash -x tools/e2e-install-test.sh for verbose trace." - echo " - Run mosaic gateway logs inside a manual container for daemon output." + phase_fail P0 "context unresolved or unsupported (uid=$(id -u) HOME=$home shell=${shell:-unknown})" +fi + +# P1 Preflight +missing_tools=() +for tool in bash curl git node npm tar; do + command -v "$tool" >/dev/null 2>&1 || missing_tools+=("$tool") +done +if [[ "${#missing_tools[@]}" -eq 0 && -n "$resolved_version" && -w "$home" ]]; then + phase_pass P1 "required tools present (including downstream git); target HOME writable; registry lane resolved" +else + phase_fail P1 "undeclared/missing prerequisite(s)=${missing_tools[*]:-none}; target_writable=$([[ -w "$home" ]] && echo yes || echo no) registry_resolved=$([[ -n "$resolved_version" ]] && echo yes || echo no)" +fi + +# P2 Acquire artifacts +if [[ -n "$resolved_version" ]] && grep -qF "$resolved_version" "$install_log"; then + phase_pass P2 "lane=$lane pinned_version=$resolved_version recorded in installer transcript" +else + phase_fail P2 "lane=$lane did not resolve and record a pinned artifact version" +fi + +# P3 Install CLI — the discriminating row. Use the known absolute path only. +cli="$prefix/bin/mosaic" +cli_version="" +if [[ -x "$cli" ]]; then + cli_version="$($cli --version 2>/dev/null | tail -n 1 | tr -d '\r' || true)" +fi +if [[ -x "$cli" && "$cli_version" == "$resolved_version" ]]; then + phase_pass P3 "absolute_path=$cli version=$cli_version equals resolved lane version" +else + phase_fail P3 "absolute_path=$cli executable=$([[ -x "$cli" ]] && echo yes || echo no) got=${cli_version:-missing} expected=${resolved_version:-unresolved}" +fi + +# P4 Framework + skills. C1 does not choose among the four disagreeing +# candidate populations. It requires the installer to publish a lane/versioned +# shipped-set declaration that a checkout-free install can resolve; C5 owns its +# contents. Without that artifact P4 is NOT-MEASURED, never a fabricated count. +declared_set="$mosaic_home/.install-shipped-skills.json" +sync_store_count=0 +runtime_link_count=0 +[[ -d "$mosaic_home/skills" ]] \ + && sync_store_count="$(find "$mosaic_home/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" +[[ -d "$home/.pi/agent/skills" ]] \ + && runtime_link_count="$(find "$home/.pi/agent/skills" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) | wc -l | tr -d ' ')" +printf '[P4-EVIDENCE] candidate_populations framework_payload=%s repo_root=%s sync_store=%s jarvis_W-jarvis_observation=7 runtime_links=%s\n' \ + "$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" "$FIXTURE_REPO_ROOT_COUNT" "$sync_store_count" "$runtime_link_count" +if [[ ! -s "$declared_set" ]]; then + phase_fail P4 "NOT-MEASURED / UNDECLARED: installer published no checkout-free, lane/versioned shipped-set artifact at $declared_set" +elif node - "$declared_set" <<'NODE' +const fs = require('fs'); +const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +if (!data || typeof data !== 'object' || !['latest', 'next'].includes(data.lane) || + typeof data.version !== 'string' || !data.version || !Array.isArray(data.skills) || data.skills.length === 0 || + data.skills.some((name) => typeof name !== 'string' || !name)) process.exit(1); +NODE +then + declared_count="$(node -p "require('$declared_set').skills.length")" + phase_pass P4 "declared shipped-set artifact parses (declared_count=$declared_count); C5 owns containment/loadability fulfillment" +else + phase_fail P4 "NOT-MEASURED / UNDECLARED: shipped-set artifact exists but is empty, malformed, or lacks lane/version" +fi + +# P5 Identity +identity_ok=true +identity_reason=() +for f in SOUL.md USER.md; do + path="$mosaic_home/$f" + if [[ ! -s "$path" ]]; then + identity_ok=false; identity_reason+=("$f missing-or-empty"); continue + fi + owner="$(stat -c '%u' "$path")"; mode="$(stat -c '%a' "$path")" + if [[ "$owner" != "$(id -u)" || "$mode" =~ [2367]$ ]]; then + identity_ok=false; identity_reason+=("$f owner=$owner mode=$mode") + fi +done +if [[ "$identity_ok" == true ]]; then + phase_pass P5 "SOUL.md and USER.md are non-empty and target-user owned with non-world-writable modes" +else + phase_fail P5 "${identity_reason[*]}" +fi + +# P6 Runtime linking / activation. #869 must remain unwired without its broker. +broker_present=false +[[ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/mosaic-lease/broker.sock" ]] && broker_present=true +dead_hooks=0 +if [[ -f "$home/.claude/settings.json" ]]; then + dead_hooks="$(grep -Ec 'mutator-gate\.py|receipt-observer-client\.py' "$home/.claude/settings.json" || true)" +fi +if [[ "$broker_present" == false && "$dead_hooks" -eq 0 ]]; then + phase_pass P6 "broker absent and #869 enforcement hooks remain inactive" +elif [[ "$broker_present" == true ]]; then + phase_pass P6 "activation broker present; hook state is evaluable" +else + phase_fail P6 "broker absent but dead enforcement hooks are active (count=$dead_hooks)" +fi + +# P7 Services — none requested by --no-auto-launch. +phase_pass P7 "no services requested by this fixture" + +# P8 Shell discoverability — actual target shell, fresh login and non-login. +base_env=(env -i HOME="$home" USER=mosaic LOGNAME=mosaic SHELL=/bin/bash PATH=/usr/local/bin:/usr/bin:/bin) +login_path="$("${base_env[@]}" /bin/bash -lc 'command -v mosaic' 2>/dev/null || true)" +nonlogin_path="$("${base_env[@]}" /bin/bash -c 'command -v mosaic' 2>/dev/null || true)" +if [[ "$login_path" == "$cli" && "$nonlogin_path" == "$cli" ]]; then + phase_pass P8 "login=$login_path nonlogin=$nonlogin_path equals P3 path" +else + phase_fail P8 "fresh bash login=${login_path:-missing} nonlogin=${nonlogin_path:-missing} expected=$cli" +fi + +manifest="$mosaic_home/.install-manifest.json" +p0_p8_failures="$failures" +if [[ "$p0_p8_failures" -eq 0 && -s "$manifest" ]]; then + phase_pass P9 "P0-P8 reasserted; manifest present" +else + phase_fail P9 "P0-P8_failed_postconditions=$p0_p8_failures manifest=$([[ -s "$manifest" ]] && echo present || echo missing); install must not certify success" +fi + +printf '[fixture] P0-P9_failed_rows=%d (includes P9 aggregate row)\n' "$failures" +if [[ "$failures" -ne 0 ]]; then exit 1 fi +TARGET +chmod 0755 /tmp/run-as-target.sh +chown mosaic:mosaic /tmp/run-as-target.sh + +exec runuser -u mosaic -- env -i \ + HOME=/home/mosaic USER=mosaic LOGNAME=mosaic SHELL=/bin/bash \ + PATH=/usr/local/bin:/usr/bin:/bin \ + FIXTURE_LANE="$FIXTURE_LANE" \ + FIXTURE_GIT_MODE="$FIXTURE_GIT_MODE" \ + FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" \ + FIXTURE_REPO_ROOT_COUNT="$FIXTURE_REPO_ROOT_COUNT" \ + /bin/bash /tmp/run-as-target.sh +INNER +chmod 0755 "$inner" + +printf '[fixture] platform=Debian/glibc image=%s target_uid=1001 lane=%s source=%s git=%s\n' "$IMAGE" "$LANE" "$SOURCE" "$GIT_MODE" +printf '[fixture] host inheritance: no bind mounts, no host HOME, no npm cache, no credentials\n' + +docker run --rm -i \ + --network bridge \ + --env FIXTURE_LANE="$LANE" \ + --env FIXTURE_SOURCE="$SOURCE" \ + --env FIXTURE_GIT_MODE="$GIT_MODE" \ + --env FIXTURE_INSTALLER_B64="$installer_b64" \ + --env FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \ + --env FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \ + "$IMAGE" /bin/bash -s < "$inner" diff --git a/tools/install-state-machine.test.sh b/tools/install-state-machine.test.sh new file mode 100755 index 00000000..cd6b85b7 --- /dev/null +++ b/tools/install-state-machine.test.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Red-first acceptance checks for #1050. This file is committed before the +# installer implementation. Do not weaken these properties to make it green. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-state-test.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT +failures=0 + +fail_case() { printf '[test] FAIL: %s\n' "$*" >&2; failures=$((failures + 1)); } +pass_case() { printf '[test] PASS: %s\n' "$*"; } + +fingerprint() { + local dir="$1" + if [[ ! -d "$dir" ]]; then printf 'ABSENT\n'; return; fi + ( + cd "$dir" || exit 1 + find . -mindepth 1 -printf '%P|%y|%m|%u|%g|%l\n' | LC_ALL=C sort + find . -type f -print0 | LC_ALL=C sort -z | xargs -0 -r sha256sum + ) | sha256sum | awk '{print $1}' +} + +make_fake_npm() { + local bin="$1" + mkdir -p "$bin" + cat > "$bin/npm" <<'FAKE' +#!/usr/bin/env bash +set -euo pipefail +case "${1:-} ${2:-} ${3:-}" in + 'view @mosaicstack/mosaic@next version') echo '0.0.50-next.999' ;; + 'view @mosaicstack/gateway@next version') echo '0.0.7-next.999' ;; + 'view @mosaicstack/mosaic version') echo '0.0.49' ;; + 'ls -g --depth=0'|'ls -g --json') echo '{"dependencies":{"@mosaicstack/mosaic":{"version":"0.0.50-next.999"},"@mosaicstack/gateway":{"version":"0.0.7-next.999"}}}' ;; + ls*) echo '{"dependencies":{"@mosaicstack/mosaic":{"version":"0.0.50-next.999"},"@mosaicstack/gateway":{"version":"0.0.7-next.999"}}}' ;; + *) echo "unexpected fake npm command: $*" >&2; exit 1 ;; +esac +FAKE + chmod 0755 "$bin/npm" +} + +printf '[test] case: --check enumerates exactly P0-P8, discriminates, and mutates nothing\n' +check_home="$TMP/check-home" +check_bin="$TMP/check-bin" +mkdir -p "$check_home/.config/mosaic/skills/alpha" "$check_home/.npm-global/bin" "$check_bin" +printf '# framework\n' > "$check_home/.config/mosaic/AGENTS.md" +printf '# skill\n' > "$check_home/.config/mosaic/skills/alpha/SKILL.md" +cat > "$check_home/.npm-global/bin/mosaic" <<'CLI' +#!/usr/bin/env bash +printf '0.0.50-next.999\n' +CLI +chmod 0755 "$check_home/.npm-global/bin/mosaic" +make_fake_npm "$check_bin" +before="$(fingerprint "$check_home")" +set +e +HOME="$check_home" MOSAIC_HOME="$check_home/.config/mosaic" MOSAIC_PREFIX="$check_home/.npm-global" \ + MOSAIC_NO_COLOR=1 PATH="$check_bin:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --check --next >"$TMP/check.log" 2>&1 +check_status=$? +set -e +after="$(fingerprint "$check_home")" + +[[ "$before" == "$after" ]] && pass_case '--check left the complete HOME fingerprint unchanged' \ + || fail_case "--check mutated HOME (before=$before after=$after)" +[[ "$check_status" -ne 0 ]] && pass_case '--check exited non-zero for failed P4/P5/P8 predicates' \ + || fail_case '--check returned zero on the deliberately broken host' + +phase_rows=0 +for phase in P0 P1 P2 P3 P4 P5 P6 P7 P8; do + count="$(grep -Ec "^\[$phase\] (PASS|FAIL):" "$TMP/check.log" || true)" + [[ "$count" -eq 1 ]] || fail_case "$phase expected exactly one PASS/FAIL row, got $count" + phase_rows=$((phase_rows + count)) +done +[[ "$phase_rows" -eq 9 ]] && pass_case '--check emitted exactly nine P0-P8 result rows' \ + || fail_case "--check emitted $phase_rows canonical rows instead of 9" +grep -q '^\[P3\] PASS:.*0\.0\.50-next\.999' "$TMP/check.log" \ + && pass_case 'P3 preserves the absolute-path exact-version discriminator' \ + || fail_case 'P3 did not PASS with the exact resolved next-lane version' +grep -q '^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:' "$TMP/check.log" \ + && pass_case 'P4 refuses fabricated precision when no shipped-set declaration exists' \ + || fail_case 'P4 did not report the declared-set population as NOT-MEASURED / UNDECLARED' +for phase in P5 P8; do + grep -q "^\[$phase\] FAIL:" "$TMP/check.log" \ + && pass_case "$phase remains an attributable expected RED" \ + || fail_case "$phase did not report its own expected failure" +done + +printf '[test] case: per-phase P2-P8 fault injection restores representative host mutations\n' +for phase in P2 P3 P4 P5 P6 P7 P8; do + home="$TMP/fault-$phase/home" + state="$TMP/fault-$phase/state" + mkdir -p "$home/.config/mosaic" "$home/.npm-global/bin" "$home/.claude" "$state" + printf 'operator-framework-sentinel\n' > "$home/.config/mosaic/operator.txt" + printf '@scope:registry=https://pre.example.invalid/\n' > "$home/.npmrc" + printf 'old-cli\n' > "$home/.npm-global/bin/mosaic" + printf '{"hooks":{"safe":true}}\n' > "$home/.claude/settings.json" + before="$(fingerprint "$home")" + set +e + HOME="$home" MOSAIC_HOME="$home/.config/mosaic" MOSAIC_PREFIX="$home/.npm-global" \ + MOSAIC_INSTALL_STATE_DIR="$state" MOSAIC_INSTALL_FAULT_AFTER="$phase" \ + MOSAIC_NO_COLOR=1 bash "$ROOT/tools/install.sh" --state-machine-self-test \ + >"$TMP/fault-$phase.log" 2>&1 + status=$? + set -e + after="$(fingerprint "$home")" + [[ "$status" -ne 0 ]] || fail_case "$phase injected fault returned zero" + grep -q "phase=$phase" "$TMP/fault-$phase.log" \ + || fail_case "$phase fault transcript did not name the injected phase" + [[ "$before" == "$after" ]] \ + && pass_case "$phase rollback restored framework/npmrc/prefix/runtime representative state" \ + || fail_case "$phase rollback mismatch (before=$before after=$after)" + if find "$state" -type f -exec grep -l '"status"[[:space:]]*:[[:space:]]*"in-progress"' {} + 2>/dev/null | grep -q .; then + fail_case "$phase left a journal in-progress" + else + pass_case "$phase left no journal falsely in-progress" + fi +done + +if [[ "$failures" -ne 0 ]]; then + printf '[test] install state-machine acceptance RED: %d failed assertion(s)\n' "$failures" >&2 + printf '[test] --check transcript: %s\n' "$TMP/check.log" >&2 + exit 1 +fi +printf '[test] installer state-machine acceptance passed\n' -- 2.54.0 From 229f643a84ebf9600e997b321be68b04b6bbb255 Mon Sep 17 00:00:00 2001 From: be-coder-05 Date: Wed, 5 Aug 2026 12:20:49 -0500 Subject: [PATCH 13/13] feat(installer): add transactional P0-P9 state machine --- .woodpecker/greenfield-install.yml | 70 ++ README.md | 30 +- docs/PRD.md | 2 +- docs/SITEMAP.md | 5 + docs/guides/installer-state-machine.md | 99 ++ docs/guides/upgrade-safety-and-recovery.md | 14 + .../1050-install-state-machine-red-fixture.md | 20 +- package.json | 2 +- packages/mosaic/framework/install.sh | 153 ++- tools/e2e-install-test.sh | 137 ++- tools/install-next-lane.test.sh | 167 ++- tools/install-state-machine.test.sh | 158 ++- tools/install.sh | 1024 ++++++++++++++++- 13 files changed, 1726 insertions(+), 155 deletions(-) create mode 100644 .woodpecker/greenfield-install.yml create mode 100644 docs/guides/installer-state-machine.md diff --git a/.woodpecker/greenfield-install.yml b/.woodpecker/greenfield-install.yml new file mode 100644 index 00000000..79756585 --- /dev/null +++ b/.woodpecker/greenfield-install.yml @@ -0,0 +1,70 @@ +# C1 expected-RED gate. The fixture must execute from zero and discriminate the +# known failed postconditions; this step is green only when the fixture itself +# returns the expected non-zero and the named evidence rows are present. +when: + - event: [pull_request, manual] + - event: push + branch: [next, main] + +steps: + greenfield-git-present: + image: node:22-bookworm-slim + commands: + - | + set +e + MOSAIC_GREENFIELD_CONTAINER=1 \ + bash tools/e2e-install-test.sh --lane next --source checkout --git present \ + > /tmp/greenfield-git-present.log 2>&1 + fixture_status=$? + set -e + cat /tmp/greenfield-git-present.log + test "$fixture_status" -eq 1 + grep -Eq '^\[fixture\] resolved lane=next .*version=[0-9]+\.[0-9]+\.[0-9]+-next\.' /tmp/greenfield-git-present.log + grep -q '^\[P1\] PASS: required tools present (including downstream git)' /tmp/greenfield-git-present.log + grep -q '^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version' /tmp/greenfield-git-present.log + grep -q '^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:' /tmp/greenfield-git-present.log + grep -q '^\[P5\] FAIL:' /tmp/greenfield-git-present.log + grep -q '^\[P6\] FAIL:' /tmp/greenfield-git-present.log + grep -q '^\[P8\] FAIL:' /tmp/greenfield-git-present.log + grep -q '^\[P9\] FAIL:' /tmp/greenfield-git-present.log + + greenfield-main-git-present: + image: node:22-bookworm-slim + commands: + - | + set +e + MOSAIC_GREENFIELD_CONTAINER=1 \ + bash tools/e2e-install-test.sh --lane main --source checkout --git present \ + > /tmp/greenfield-main-git-present.log 2>&1 + fixture_status=$? + set -e + cat /tmp/greenfield-main-git-present.log + test "$fixture_status" -eq 1 + grep -Eq '^\[fixture\] resolved lane=main .*version=[0-9]+\.[0-9]+\.[0-9]+' /tmp/greenfield-main-git-present.log + grep -q '^\[P1\] PASS: required tools present (including downstream git)' /tmp/greenfield-main-git-present.log + grep -q '^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version' /tmp/greenfield-main-git-present.log + grep -q '^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:' /tmp/greenfield-main-git-present.log + grep -q '^\[P5\] FAIL:' /tmp/greenfield-main-git-present.log + grep -q '^\[P6\] FAIL:' /tmp/greenfield-main-git-present.log + grep -q '^\[P8\] FAIL:' /tmp/greenfield-main-git-present.log + grep -q '^\[P9\] FAIL:' /tmp/greenfield-main-git-present.log + + greenfield-git-absent: + image: node:22-bookworm-slim + commands: + - | + set +e + MOSAIC_GREENFIELD_CONTAINER=1 \ + bash tools/e2e-install-test.sh --lane next --source checkout --git absent \ + > /tmp/greenfield-git-absent.log 2>&1 + fixture_status=$? + set -e + cat /tmp/greenfield-git-absent.log + test "$fixture_status" -eq 1 + grep -q '^\[fixture\] installer_exit=1 done_claims=0' /tmp/greenfield-git-absent.log + grep -q '^\[P1\] FAIL: undeclared/missing prerequisite(s)=git;' /tmp/greenfield-git-absent.log + grep -q '^\[P3\] FAIL: .*executable=no' /tmp/greenfield-git-absent.log + if grep -q 'Done\.' /tmp/greenfield-git-absent.log; then + echo 'git-absent state-machine run falsely certified Done' >&2 + exit 1 + fi diff --git a/README.md b/README.md index f4d3c290..5bd090fa 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi — ## Quick Install ```bash -curl -fsSL https://mosaicstack.dev/install.sh | bash +bash -o pipefail -c 'curl -fsSL https://mosaicstack.dev/install.sh | bash' ``` Or use the direct URL: @@ -32,13 +32,13 @@ This installs both components: ### 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 | +| 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 | Exact `@next` CLI/gateway versions + pinned `next` framework commit; pinned-source fallback | +| 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. +`--next` selects the prerelease integration lane. It installs the exact CLI/gateway versions resolved from the aligned `@next` tags, and pins the framework archive to the resolved `next` commit. If the registry path fails, it builds from that pinned source. An explicit `--ref` or `MOSAIC_REF` wins and selects source mode. After install, the wizard runs automatically or you can invoke it manually: @@ -48,10 +48,14 @@ mosaic wizard # Full guided setup (gateway install → verify) ### Requirements -- Node.js ≥ 20 -- npm (for global @mosaicstack/mosaic install) +- Linux x86_64 with glibc (Debian is the greenfield CI platform; musl/Alpine, macOS, and ARM64 currently fail as unsupported) +- Node.js ≥ 20 and npm ≥ 9 +- `bash`, `curl`, `git`, `python3`, `tar`, and standard core utilities (`awk`, `df`, `find`, `flock`, `grep`, `install`, `realpath`, `sed`, `sha256sum`, `stat`, `sync`) +- At least 256 MiB free disk and 1,000 free inodes at the npm prefix - One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent) +The installer evaluates canonical phases P0–P9 and does not print `Done.` unless every committed postcondition passes. A failed phase exits non-zero, names the phase, and points to its durable journal under `${XDG_STATE_HOME:-~/.local/state}/mosaic/install/`. See [Installer state machine and recovery](docs/guides/installer-state-machine.md). + ## Usage ### Launching Agent Sessions @@ -347,7 +351,7 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding), Run the installer again — it handles upgrades automatically: ```bash -curl -fsSL https://mosaicstack.dev/install.sh | bash +bash -o pipefail -c 'curl -fsSL https://mosaicstack.dev/install.sh | bash' ``` Or use the direct URL: @@ -368,17 +372,17 @@ The CLI also performs a background update check on every invocation (cached for ### Installer Flags ```bash -bash tools/install.sh --check # Version check only +bash tools/install.sh --check # Side-effect-free P0-P8 postcondition check bash tools/install.sh --framework # Framework only (skip npm CLI) 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 --next # Prerelease lane: exact @next versions + pinned-source fallback bash tools/install.sh --dev # Contributor lane: source build at --ref/main bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next) bash tools/install.sh --yes # Non-interactive, accept all defaults bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard ``` -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. `--check` reports one PASS/FAIL row for each P0–P8 predicate and exits non-zero if any row fails; it does not create the npm prefix, lock, journal, manifest, or runtime files. ## Contributing diff --git a/docs/PRD.md b/docs/PRD.md index e3e8c606..b92d38ac 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1379,7 +1379,7 @@ A from-zero install can report success while leaving the target host unusable be ### Normative requirements -1. The installer SHALL implement the canonical P0–P9 numbering from the greenfield-install PRD v2: P0 Resolve context; P1 Preflight; P2 Acquire artifacts; P3 Install CLI; P4 Install framework + skills; P5 Identity; P6 Runtime linking / activation; P7 Services; P8 Shell discoverability; P9 Verify + commit. +1. The installer SHALL implement the canonical P0–P9 numbering from the greenfield-install PRD v2: P0 Resolve context; P1 Preflight; P2 Acquire artifacts; P3 Install CLI; P4 Install framework + skills; P5 Identity; P6 Runtime linking / activation; P7 Services; P8 Shell discoverability; P9 Verify + commit. P2 is scoped to installer-distribution artifacts and SHALL NOT foreclose credentialed downstream acquisition. P5 owns validating any credential capability required by requested downstream work; P7 may provision credential-dependent resources only after that P5 postcondition commits. 2. Every phase SHALL declare preconditions, action, committed postconditions, and rollback. An unverifiable postcondition SHALL fail the install non-zero with the named phase and a remediation line; no best-effort failure may still certify success. P1's required-tool closure includes tools invoked by later phases, including `git`; a downstream prerequisite may not remain undeclared and degrade silently. 3. A durable mutation journal SHALL open before the first mutation and commit at P9. Fallible command output needed to diagnose a phase SHALL be journaled and surfaced, never discarded. 4. `--check` SHALL run exactly the P0–P8 postcondition predicates without mutation, report each phase PASS/FAIL, and exit non-zero if any predicate fails. diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md index 3f5a296c..31411227 100644 --- a/docs/SITEMAP.md +++ b/docs/SITEMAP.md @@ -9,6 +9,11 @@ - [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary. - [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window. +## Installation and upgrades + +- [Installer state machine and recovery](guides/installer-state-machine.md) — canonical P0–P9 phases, side-effect-free checks, durable journal states, rollback/remediation, and the Debian greenfield CI gate. +- [Upgrade safety and recovery](guides/upgrade-safety-and-recovery.md) — framework ownership, durable operator snapshots, verify net, and projection regeneration. + ## CLI and skill management - [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior. diff --git a/docs/guides/installer-state-machine.md b/docs/guides/installer-state-machine.md new file mode 100644 index 00000000..1b2eb840 --- /dev/null +++ b/docs/guides/installer-state-machine.md @@ -0,0 +1,99 @@ +# Installer State Machine and Recovery + +The unified installer uses a transactional P0–P9 model. It may report success only after P9 reasserts every applicable committed postcondition. Internal phases invoke the CLI by P3's absolute path; shell discovery is checked only at P8. + +## Canonical phases + +| Phase | Responsibility | Failure disposition | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| P0 Resolve context | State target user, HOME, shell, privilege mode, architecture, libc, Node, and npm | Fail before mutation | +| P1 Preflight | Validate downstream tool closure (including `git` and `python3`), writable prefix, registry lane, disk/inodes, and exclusive lock | Fail before target mutation | +| P2 Acquire artifacts | Resolve exact registry versions and an immutable framework commit; record lane and SHA-256 | Discard temporary work | +| P3 Install CLI | Install at the configured absolute prefix and require exact resolved version | Restore the prior prefix/npmrc snapshot | +| P4 Install framework + skills | Sync framework and consume a checkout-free, lane/versioned shipped-skill declaration | Restore prior framework/runtime trees | +| P5 Identity | Validate SOUL/USER content, owner, and mode; establish any credential capability requested downstream | Restore generated identity/credential binding | +| P6 Runtime linking / activation | Evaluate activation honestly; never treat dead enforcement hooks as active readiness | Restore runtime activation files | +| P7 Services | Provision only requested services/resources after any required P5 credential commits | Stop and restore requested services/resources | +| P8 Shell discoverability | Require fresh login and non-login shells of the actual target shell to resolve P3's path | Restore shell profiles | +| P9 Verify + commit | Re-run P0–P8, commit the manifest, and seal the journal | Leave an honestly reported resumable failure or restore the pre-install snapshot | + +The phase numbers are a cross-workstream contract and must not be renumbered. + +## Side-effect-free check + +```bash +bash tools/install.sh --check # stable/latest lane +bash tools/install.sh --check --next # prerelease lane +``` + +`--check`: + +- emits exactly one `[P0]` through `[P8]` PASS/FAIL row; +- exits non-zero if any predicate fails; +- does not create the npm prefix, lock, journal, manifest, shell profile, or runtime file; +- uses temporary npm observation storage outside the target HOME and removes it before exit. + +P4 currently fails as `NOT-MEASURED / UNDECLARED` until the installer publishes `~/.config/mosaic/.install-shipped-skills.json`. C1 deliberately does not select among the conflicting candidate populations; C5 owns publishing and fulfilling that declaration. Once present, the P4 predicate requires the declaration's lane/version to match the resolved install and every named skill to remain contained under `skills//SKILL.md` with matching loadable frontmatter. + +## Durable journal + +Each mutating run creates a private transaction directory: + +```text +${XDG_STATE_HOME:-~/.local/state}/mosaic/install/ + active.json + / + journal.ndjson + journal.ndjson.sha256 # committed runs only + commands.log + snapshot/ +``` + +Before each mutation scope is touched, `journal.ndjson` records: + +- phase and path; +- whether prior state existed and where its snapshot lives; +- the reversal action; +- the captured command-output location and command status. + +Journal, action-status, manifest, or command-log write/sync failure is fatal. An unrecorded mutation is not allowed. Successful P9 runs append a seal event, write the SHA-256 sidecar, and make the journal and sidecar read-only. Required P4/P6 action failures are persisted in the manifest so a later `--check` cannot turn a failed action into a false pass. + +Rollback roots must be non-overlapping, non-symlinked, target-user-owned strict descendants of canonical `HOME`; unsafe custom `MOSAIC_HOME`/`MOSAIC_PREFIX` values fail at P0. The same validation runs again immediately before recursive rollback. The OS lock is concurrency authority: if a process dies while `active.json` still says `in-progress`, a retry that acquires the free lock preserves the stale projection as `prior-active.json` and proceeds from the honestly retained partial state. + +`active.json` is the current projection: + +- `in-progress`: incomplete/open transaction; +- `rolled-back`: a fault restored the snapshot; +- `rollback-failed`: restoration failed or refused a replaced/unsafe target and requires manual recovery; +- `failed-resumable`: named postconditions failed and the recorded partial state remains for remediation; +- `committed`: P9 passed and the journal is sealed. + +## Failure recovery + +1. Read the named phase and remediation line from installer stderr. +2. Inspect `active.json`, then the referenced `journal.ndjson` and `commands.log`. Command output needed to diagnose a failure is preserved and surfaced; it is not redirected away. +3. For `rolled-back`, verify the target paths match their pre-install state before retrying. +4. For `failed-resumable`, repair the named phase owner requirement, then run `install.sh --check` before retrying the installer. +5. Do not activate the #869 enforcement hooks merely to turn P6 green. A broker-less host with those hooks is a failed P6 state. + +## Greenfield CI gate + +`.woodpecker/greenfield-install.yml` runs `tools/e2e-install-test.sh` from zero in Debian/glibc as a non-root uid with `env -i`. No host HOME, npm cache, credentials, or bind mount enters the target process. Checkout mode packages the complete current checkout into an archive, pins its SHA-256 through an internal fixture seam, and copies the self-contained fixture into the container; framework-installer changes in the PR are therefore exercised rather than fetched from an older remote branch. + +The C1 gate intentionally validates an attributable RED while C2–C5 remain open: + +- `git` present: P1 and strict P3 pass; P4/P5/P6/P8 fail for their own reasons; P9 refuses success. +- `git` absent: P1 fails before target mutation and the installer emits no `Done.`. + +The fixture is lane-parametric: + +```bash +bash tools/e2e-install-test.sh --lane next --git present +bash tools/e2e-install-test.sh --lane main --git present +``` + +CI exercises both lane parameters as expected-RED structural checks. The authoritative main-lane promotion acceptance and issue closure remain owned by #1037. + +## Source trust boundary + +Remote source mode pins the resolved commit, records the archive SHA-256, limits compressed/expanded size and entry count, and rejects traversal, links, devices, and special files before extraction. This provides immutable run provenance and archive safety, not an independent authenticity root. Signed artifact metadata/provenance is explicitly deferred by the canonical greenfield PRD; C1 does not invent a signing system. The checkout CI seam does verify an expected digest supplied independently by the fixture. diff --git a/docs/guides/upgrade-safety-and-recovery.md b/docs/guides/upgrade-safety-and-recovery.md index 1f755cb7..ba6837c2 100644 --- a/docs/guides/upgrade-safety-and-recovery.md +++ b/docs/guides/upgrade-safety-and-recovery.md @@ -12,6 +12,20 @@ with no snapshot to fall back to. Protection is layered. Each layer is independent; a later layer catches what an earlier one misses. +## Layer 0 — Transaction journal (install-wide recovery) + +The unified installer opens a private journal under +`${XDG_STATE_HOME:-~/.local/state}/mosaic/install/` before the first target +mutation. Every mutation scope records its path, prior snapshot, and reversal +instructions before it is touched. Journal write/sync failure is fatal, and P9 +seals successful journals with a SHA-256 sidecar. See +[Installer state machine and recovery](./installer-state-machine.md). + +This transaction journal is distinct from the retained operator-only backup +below. The transaction journal is required for correctness and rollback; +Layer 2's durable backup remains a separately stated, fail-open recovery bonus +for a manifest bug that the normal transaction did not detect. + ## Layer 1 — Manifest-owned sync (prevention) The single source of truth for ownership is diff --git a/docs/scratchpads/1050-install-state-machine-red-fixture.md b/docs/scratchpads/1050-install-state-machine-red-fixture.md index 33f42fe9..a8a9b609 100644 --- a/docs/scratchpads/1050-install-state-machine-red-fixture.md +++ b/docs/scratchpads/1050-install-state-machine-red-fixture.md @@ -6,7 +6,7 @@ Implement C1 from the canonical greenfield-install PRD v2: a transactional P0– ## Authority and scope -- Canonical requirements: `jason.woltje/jarvis-brain` `docs/plans/2026-08-04-greenfield-install-blockers-PRD-v2.md`, read from local `origin/main` object `b2b6ed41f5aff5ea964e69b7c701cb45718742fa`; remote currency is **unestablished** because authenticated fetch returned repository-not-found. +- Canonical requirements: `jason.woltje/jarvis-brain` `docs/plans/2026-08-04-greenfield-install-blockers-PRD-v2.md`. Currency was re-derived after compaction: authenticated fetch resolved `origin/main` to `cb23e5fbc8a282fa967b93d7a134fa48d11b4bb1`; the PRD and charters are byte-identical to the previously read remote copies. - Tracking: `mosaicstack/stack#1050` on `git.mosaicstack.dev` (author read back as `be-coder-05`). - Base: `origin/next` `4df478cdd150fdf8d52ea109f02ade5d85017acd`. - Out of scope: PATH, skills, headless wizard/identity, activation remediation, #869 wiring, RM-02, main promotion. @@ -40,23 +40,29 @@ Implement C1 from the canonical greenfield-install PRD v2: a transactional P0– ## Progress -- [x] Charter, doctrine, delivery/CI/QA/docs guides read. -- [x] Canonical PRD v2 and charters read from local origin object; numbering reconciles with the TL spec. No numbering conflict found. TL additions (early durable journal and INV-C) are additive, not contradictory. +- [x] Charter, doctrine, delivery/CI/QA/docs guides read and re-anchored after compaction. +- [x] Canonical PRD v2/v3 addenda and charters read from fetched `origin/main`; numbering reconciles with the TL spec. No numbering conflict found. INV-B/C/D are binding and implemented without renumbering. - [x] Target base reachability verified with `merge-base --is-ancestor`. - [x] Issue #1050 created and provider author read back. - [x] Initial RED captured; TL rejected P4's repo-root count as a false RED. Four populations disagree (framework payload 1, repo root 13, sync store 101 in the fixture, W-jarvis observation 7), so C1 now requires a checkout-free declared shipped-set artifact and reports P4 `NOT-MEASURED / UNDECLARED` until C5 supplies it. - [x] P6 strengthens #869: the two dead enforcement hooks reproduce from zero on a clean broker-less container. C1 asserts the breach but neither wires nor unwires it. - [x] P1 false pass identified from the P4 evidence row: `git` is absent from the Debian base and was undeclared even though skill sync shells out to it. C1 adds `git` to P1; the fixture matrix preserves absent/present controls. The prior claim that web1's missing runtime skills reproduce this greenfield mechanism is withdrawn by the TL and is not carried here. -- [ ] Corrected RED transcript captured and reported. -- [ ] State machine implemented. -- [ ] Reviews complete. +- [x] Corrected RED transcript captured and reported, including the git-present/absent controls and strict P3 PASS. +- [x] State-machine implementation complete: private pre-mutation journal/snapshot, P0–P8 `--check`, P2–P8 fault seam, rollback, durable manifest/journal seal, action-status persistence, safe rollback roots, and stale-projection recovery. +- [x] Debian/glibc checkout fixture now packages the complete current checkout, verifies its digest in-container, and reaches the expected attributable RED without host inheritance. +- [ ] Reviews complete. Automated review defects around Bash conditional errexit, explicit exits, P4/P6 persisted action status, dev/offline source resolution, stale locks, checkout coverage, and rollback path safety were remediated. Remaining automated objections are the charter-mandated expected RED/C5 boundary and signed provenance, which the canonical PRD explicitly defers; independent informed review is still required. ## Risks / blockers - The deployed create wrappers do not expose `--dry-run`; identity preflight was performed through `pr-merge.sh --dry-run` on the same HOMELAB repo, which resolved `git.mosaicstack.dev` + `be-coder-05`. The issue create then fell back from tea to the API but provider read-back confirmed author `be-coder-05`. - `next` is an integration lane; `main` promotion remains #1037-owned. - #869 must remain staged and inactive. +- Late sequencing input MB-BRAIN-01 is accommodated without implementation or renumbering: P2 covers installer distribution only; P5 owns requested credential capability; P7 leaves an ordered seam for credential-dependent resource provisioning after P5. ## Verification log -(To be updated with exact commands and resulting objects.) +- `bash -n` and ShellCheck pass for all changed shell surfaces; `git diff --check` passes. +- `bash tools/install-state-machine.test.sh` passes, including exact P0–P8 rows, good/bad discrimination, persisted P4/P6 action failures, P2–P8 rollback, unsafe/overlapping/symlink roots, stale `active.json`, and fatal journal initialization. +- `bash tools/install-next-lane.test.sh` passes, including exact `@next` versions, immutable source fallback, source-build/archive-failure rollback, offline `--dev`, explicit refs, and prerelease suffix mismatch. +- `bash tools/e2e-install-test.sh --lane next --source checkout --git present` returns the required expected RED in clean Debian/glibc as uid 1001: installer P0/P1/P2/P3/P7 PASS; P4/P5/P6/P8 and P9 blocking; no `Done.` claim; checkout archive digest pinned and current framework installer exercised. +- Earlier repository gates passed: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm test:installer`, upgrade manifest/rollback/durable-snapshot/migration suites, and focused `@mosaicstack/mosaic` tests with an isolated npm prefix. Full rerun is required after final edits. diff --git a/package.json b/package.json index 602bb7ee..82155fdc 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "typecheck": "pnpm preflight && turbo run typecheck", "test:checkout": "node --test scripts/*.test.mjs", "test": "pnpm test:checkout && turbo run test && pnpm run test:installer", - "test:installer": "bash tools/install-next-lane.test.sh", + "test:installer": "bash tools/install-state-machine.test.sh && bash tools/install-next-lane.test.sh", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", "prepare": "node scripts/install-hooks.mjs" diff --git a/packages/mosaic/framework/install.sh b/packages/mosaic/framework/install.sh index 7d262a24..f4352276 100755 --- a/packages/mosaic/framework/install.sh +++ b/packages/mosaic/framework/install.sh @@ -58,6 +58,7 @@ done # packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt. # Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0). # shellcheck source=tools/_lib/manifest.sh +# shellcheck disable=SC1091 # Dynamic SOURCE_DIR; the path is validated by set -e. source "$SOURCE_DIR/tools/_lib/manifest.sh" # Which paths a keep-mode upgrade may touch is no longer a hand-maintained @@ -222,12 +223,14 @@ prune_durable_snapshots() { [[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5 list="$(mktemp)" if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then + warn "Backup pruning skipped; policy: retention cleanup is optional and a failed enumeration must preserve every existing recovery snapshot." rm -f "$list"; return 0 fi # Newest-first ordering needs `sort` (`-o` writes back in place — no `mv` # dependency); if it is somehow unavailable, leave the backups untouched rather # than risk pruning in an undefined order. if ! LC_ALL=C sort -r -o "$list" "$list" 2>/dev/null; then + warn "Backup pruning skipped; policy: ordering failure preserves all snapshots rather than risking deletion in an undefined order." rm -f "$list"; return 0 fi while IFS= read -r d; do @@ -266,7 +269,11 @@ make_durable_snapshot() { warn "Durable snapshot skipped: cannot create backup dir $root (upgrade continues; operator files remain manifest-protected)." return 0 fi - chmod 700 "$root" 2>/dev/null || true + if ! chmod 700 "$root"; then + umask "$old_umask" + warn "Durable snapshot skipped: backup root permissions could not be made private; policy: never write operator data to an insufficiently protected location." + return 0 + fi dir="$root/pre-update-$ts" if [[ -e "$dir" ]]; then # same-second re-run: disambiguate local n=1; while [[ -e "$dir-$n" ]]; do n=$((n + 1)); done; dir="$dir-$n" @@ -281,7 +288,10 @@ make_durable_snapshot() { if ! enumerate_operator_files "$list"; then umask "$old_umask" warn "Durable snapshot skipped: could not enumerate operator files (upgrade continues)." - rm -f "$list"; rmdir "$dir" 2>/dev/null || true + rm -f "$list" + if ! rmdir "$dir"; then + warn "Durable snapshot cleanup left $dir in place; policy: preserve unexpected content rather than deleting it recursively." + fi return 0 fi while IFS= read -r -d '' rel; do @@ -292,12 +302,18 @@ make_durable_snapshot() { warn "Durable snapshot: could not copy operator file '$rel' (skipped)." continue fi - chmod 600 "$dst" 2>/dev/null || true + if ! chmod 600 "$dst"; then + rm -f "$dst" + warn "Durable snapshot: copied '$rel' could not be made private and was removed; policy: do not retain an insecure recovery copy." + continue + fi count=$((count + 1)) done < "$list" rm -f "$list" - # Tighten every dir the copy created (mkdir -p honors umask, but be explicit). - find "$dir" -type d -exec chmod 700 {} + 2>/dev/null || true + # Tighten every dir the copy created (mkdir -p already honored umask 077). + if ! find "$dir" -type d -exec chmod 700 {} +; then + warn "Durable snapshot directory permission recheck failed; policy: continue because every directory was created under umask 077, while retaining the diagnostic." + fi umask "$old_umask" # UMASK-RESTORE-NORMAL — restore before the upgrade proper resumes (see above) DURABLE_SNAPSHOT_DIR="$dir" ok "Durable pre-update snapshot: $count operator file(s) saved to $dir (recover with: mosaic restore --list)" @@ -344,7 +360,9 @@ verify_operator_surface() { continue fi if cp "$snap" "$cur"; then - chmod 600 "$cur" 2>/dev/null || true + if ! chmod 600 "$cur"; then + warn "Operator file '$rel' was restored but its mode could not be tightened to 0600; policy: preserve recovered content and require manual permission repair." + fi warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel" healed=$((healed + 1)) else @@ -535,7 +553,7 @@ sync_framework_keep() { # (unreadable dir) is surfaced as a warning rather than silently swallowed; # the "directory not empty" races we tolerate are ignored via -delete's own # rc, not by hiding stderr — so a real error is still visible to the operator. - if ! find "$dst/$root" -type d -empty -delete 2>/dev/null; then + if ! find "$dst/$root" -type d -empty -delete; then warn "prune: could not fully sweep empty framework dirs under $root (left as-is)" fi done < <(manifest_subtree_roots) @@ -581,7 +599,7 @@ run_migrations() { MIGRATION_REMOVED_PATHS+=("bin" "rails") if [[ -d "$TARGET_DIR/bin" ]]; then ok "Removing legacy bin/ directory (executables now in npm CLI)" - rm -rf "$TARGET_DIR/bin" + rm -rf "${TARGET_DIR:?}/bin" fi # Remove old mosaic PATH entry from shell profiles @@ -706,13 +724,23 @@ mkdir -p "$TARGET_DIR/credentials" # by `mosaic init` from templates with user-supplied values. reconcile_framework_files -# Ensure tool scripts are executable -find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true -find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true +# Ensure tool scripts are executable. These are P4 postconditions, not +# best-effort cleanup: a chmod failure leaves shipped tools unloadable. +if ! find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} +; then + fail "Could not mark shipped shell tools executable." + exit 1 +fi +if ! find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} +; then + fail "Could not mark shipped runtime scripts executable." + exit 1 +fi # git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh -# suffix — git resolves credential helpers by exact name/path, not extension — -# so the *.sh glob above does not cover it; chmod it explicitly. -[[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] && chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic" 2>/dev/null || true +# suffix — git resolves credential helpers by exact name/path, not extension. +if [[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] \ + && ! chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic"; then + fail "Could not mark git-credential-mosaic executable." + exit 1 +fi ok "Framework synced to $TARGET_DIR" @@ -739,49 +767,110 @@ step "Post-install tasks" SCRIPTS="$TARGET_DIR/tools/_scripts" +# Capture every fallible post-install command. A failure's text is surfaced and +# also appended to the parent transaction's private command log. Failure to +# write that log is fatal: continuing would recreate the false-clean diagnosis +# INV-C forbids. +record_phase_outcome() { + local phase="$1" status="$2" reason="$3" + [[ -n "${MOSAIC_INSTALL_PHASE_STATUS_FILE:-}" ]] || return 0 + if ! printf '%s\t%s\t%s\n' "$phase" "$status" "$reason" >> "$MOSAIC_INSTALL_PHASE_STATUS_FILE" \ + || ! sync "$MOSAIC_INSTALL_PHASE_STATUS_FILE"; then + fail "Could not durably record $phase action outcome for the parent transaction." + exit 1 + fi +} + +run_captured() { + local label="$1" output status=0 + shift + output="$(mktemp "${TMPDIR:-/tmp}/mosaic-post-install.XXXXXX.log")" + if "$@" >"$output" 2>&1; then status=0; else status=$?; fi + if [[ -n "${MOSAIC_INSTALL_COMMAND_LOG:-}" ]]; then + if ! { printf '\n=== %s (exit=%s) ===\n' "$label" "$status"; cat "$output"; } >> "$MOSAIC_INSTALL_COMMAND_LOG" \ + || ! sync "$MOSAIC_INSTALL_COMMAND_LOG"; then + cat "$output" >&2 + rm -f "$output" + fail "Could not durably append '$label' diagnostics to the install command log." + exit 1 + fi + fi + if [[ "$status" -ne 0 ]]; then cat "$output" >&2; fi + rm -f "$output" + return "$status" +} + if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then link_args=() [[ "$ALLOW_INACTIVE_ENFORCEMENT" == "1" ]] && link_args+=(--allow-inactive-enforcement) - # stdout is suppressed as before, but stderr is left connected: the - # install-ordering guard's FAIL LOUD message (#869 Point-1 C2) must reach - # the operator, not be swallowed silently. - if "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}" >/dev/null; then + if run_captured "runtime asset linking" "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}"; then + record_phase_outcome P6 committed "runtime asset linker exited zero" ok "Runtime assets linked" else - warn "Runtime asset linking failed (non-fatal) — see message above for details." + record_phase_outcome P6 failed "runtime asset linker exited non-zero" + warn "Runtime asset linking did not commit; policy: continue only to enumerate all phase diagnostics, while P6/P9 remain blocking." fi +else + record_phase_outcome P6 failed "required runtime asset linker is missing or not executable" + warn "Runtime asset linking was not attempted; policy: a missing required linker remains a blocking P6/P9 failure." fi if [[ -x "$SCRIPTS/mosaic-ensure-sequential-thinking" ]]; then - if "$SCRIPTS/mosaic-ensure-sequential-thinking" >/dev/null 2>&1; then + if run_captured "sequential-thinking setup" "$SCRIPTS/mosaic-ensure-sequential-thinking"; then ok "sequential-thinking MCP configured" + elif [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then + record_phase_outcome P6 failed "sequential-thinking setup failed under diagnostic-continuation compatibility mode" + warn "sequential-thinking setup did not commit; policy: the unified installer compatibility flag allows diagnostic continuation, while P6/P9 remain blocking." else - if [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then - warn "sequential-thinking MCP setup bypassed (MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1)" - else - fail "sequential-thinking MCP setup failed (hard requirement)." - exit 1 - fi + fail "sequential-thinking MCP setup failed (hard requirement)." + exit 1 fi fi if [[ -x "$SCRIPTS/mosaic-ensure-excalidraw" ]]; then - "$SCRIPTS/mosaic-ensure-excalidraw" >/dev/null 2>&1 && ok "excalidraw MCP configured" || warn "excalidraw MCP setup failed (non-fatal)" + if run_captured "excalidraw setup" "$SCRIPTS/mosaic-ensure-excalidraw"; then + ok "excalidraw MCP configured" + else + warn "excalidraw setup did not commit; policy: optional integration failure is retained in the journal and does not define core install readiness." + fi fi -if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" != "1" ]] && [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then - "$SCRIPTS/mosaic-sync-skills" >/dev/null 2>&1 && ok "Skills synced" || warn "Skills sync failed (non-fatal)" +if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" == "1" ]]; then + record_phase_outcome P4 failed "required skills sync explicitly skipped" + warn "Skills sync was skipped; policy: diagnostic continuation is allowed, but P4/P9 cannot certify an incomplete requested framework install." +elif [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then + if run_captured "skills sync" "$SCRIPTS/mosaic-sync-skills"; then + record_phase_outcome P4 committed "skills sync exited zero" + ok "Skills synced" + else + record_phase_outcome P4 failed "skills sync exited non-zero" + warn "Skills sync did not commit; policy: continue to collect P4 diagnostics, but P4/P9 must not certify the install." + fi +else + record_phase_outcome P4 failed "required skills sync command is missing or not executable" + warn "Skills sync was not attempted; policy: a missing required sync command remains a blocking P4/P9 failure." fi if [[ -x "$SCRIPTS/mosaic-migrate-local-skills" ]]; then - "$SCRIPTS/mosaic-migrate-local-skills" --apply >/dev/null 2>&1 && ok "Local skills migrated" || warn "Local skill migration failed (non-fatal)" + if run_captured "local skills migration" "$SCRIPTS/mosaic-migrate-local-skills" --apply; then + ok "Local skills migrated" + else + record_phase_outcome P4 failed "local skills migration exited non-zero" + warn "Local skill migration did not commit; policy: preserve user content and continue diagnostics, while P4/P9 remain blocking." + fi fi if [[ -x "$SCRIPTS/mosaic-doctor" ]]; then - "$SCRIPTS/mosaic-doctor" >/dev/null 2>&1 && ok "Health audit passed" || warn "Health audit reported issues — run 'mosaic doctor' for details" + if run_captured "health audit" "$SCRIPTS/mosaic-doctor"; then + ok "Health audit passed" + else + warn "Health audit found unresolved state; policy: preserve its diagnostics and let P9 issue the authoritative failure." + fi fi -# Write version stamp AFTER everything succeeds +# The version stamp records the successfully committed framework file sync. +# Post-install failures are carried separately into P4/P6 and cannot be erased +# by this stamp. write_framework_version # ── Summary ────────────────────────────────────────────────── diff --git a/tools/e2e-install-test.sh b/tools/e2e-install-test.sh index 9f64eca8..241d3d40 100755 --- a/tools/e2e-install-test.sh +++ b/tools/e2e-install-test.sh @@ -14,6 +14,7 @@ SOURCE="${MOSAIC_INSTALL_SOURCE:-checkout}" IMAGE="${MOSAIC_INSTALL_IMAGE:-node:22-bookworm-slim}" GIT_MODE="${MOSAIC_INSTALL_GIT_MODE:-present}" INSTALLER_FILE="${MOSAIC_FIXTURE_INSTALLER_FILE:-$ROOT/tools/install.sh}" +IN_CLEAN_CONTAINER="${MOSAIC_GREENFIELD_CONTAINER:-0}" usage() { cat <<'EOF' @@ -39,38 +40,62 @@ case "$LANE" in next|main) ;; *) echo "[fixture] unsupported lane '$LANE' (expec case "$SOURCE" in checkout|remote) ;; *) echo "[fixture] unsupported source '$SOURCE' (expected checkout|remote)" >&2; exit 2 ;; esac case "$GIT_MODE" in present|absent) ;; *) echo "[fixture] unsupported git mode '$GIT_MODE' (expected present|absent)" >&2; exit 2 ;; esac -if ! command -v docker >/dev/null 2>&1; then - echo "[fixture] FAIL: Docker is required; greenfield validation was NOT RUN." >&2 - exit 2 -fi -if ! docker info >/dev/null 2>&1; then - echo "[fixture] FAIL: Docker daemon is unavailable; greenfield validation was NOT RUN." >&2 - exit 2 +if [[ "$IN_CLEAN_CONTAINER" != "1" ]]; then + if ! command -v docker >/dev/null 2>&1; then + echo "[fixture] FAIL: Docker is required; greenfield validation was NOT RUN." >&2 + exit 2 + fi + if ! docker info >/dev/null 2>&1; then + echo "[fixture] FAIL: Docker daemon is unavailable; greenfield validation was NOT RUN." >&2 + exit 2 + fi fi installer_b64="" framework_payload_count="NOT-MEASURED" repo_root_count="NOT-MEASURED" +checkout_archive="" +checkout_digest="" +checkout_content_id="" if [[ "$SOURCE" == "checkout" ]]; then installer_b64="$(base64 -w0 "$INSTALLER_FILE")" [[ -d "$ROOT/packages/mosaic/framework/skills" ]] \ && framework_payload_count="$(find "$ROOT/packages/mosaic/framework/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" [[ -d "$ROOT/skills" ]] \ && repo_root_count="$(find "$ROOT/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" + checkout_archive="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-checkout.XXXXXX.tar.gz")" + repo_parent="$(dirname "$ROOT")" + repo_name="$(basename "$ROOT")" + tar -C "$repo_parent" \ + --exclude='*/.git' --exclude='*/node_modules' --exclude='*/dist' \ + --exclude='*/coverage' --exclude='*/.turbo' --exclude='*/.mosaic-test-work' \ + --exclude='*/.env' --exclude='*/.env.*' \ + -czf "$checkout_archive" "$repo_name" + checkout_digest="$(sha256sum "$checkout_archive" | awk '{print $1}')" + checkout_content_id="${checkout_digest:0:40}" fi inner="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-inner.XXXXXX.sh")" -trap 'rm -f "$inner"' EXIT +trap 'rm -f "$inner" "$checkout_archive"' EXIT cat > "$inner" <<'INNER' #!/usr/bin/env bash set -euo pipefail export DEBIAN_FRONTEND=noninteractive apt-get update -qq -packages=(bash ca-certificates curl jq passwd util-linux) +packages=(bash ca-certificates curl jq passwd python3 util-linux) [[ "$FIXTURE_GIT_MODE" == "present" ]] && packages+=(git) apt-get install -y -qq "${packages[@]}" >/dev/null +if [[ "$FIXTURE_SOURCE" == "checkout" ]]; then + awk 'found { print } /^__MOSAIC_CHECKOUT_ARCHIVE__$/ { found=1; next }' "$0" | base64 -d > /tmp/source-checkout.tar.gz + actual_checkout_digest="$(sha256sum /tmp/source-checkout.tar.gz | awk '{print $1}')" + if [[ "$actual_checkout_digest" != "$FIXTURE_CHECKOUT_SHA256" ]]; then + echo "[fixture] checkout archive transport digest mismatch" >&2 + exit 1 + fi +fi + useradd --create-home --uid 1001 --shell /bin/bash mosaic install -d -o mosaic -g mosaic /home/mosaic/work @@ -130,7 +155,7 @@ fi # P1 Preflight missing_tools=() -for tool in bash curl git node npm tar; do +for tool in bash curl git node npm python3 tar; do command -v "$tool" >/dev/null 2>&1 || missing_tools+=("$tool") done if [[ "${#missing_tools[@]}" -eq 0 && -n "$resolved_version" && -w "$home" ]]; then @@ -173,18 +198,34 @@ printf '[P4-EVIDENCE] candidate_populations framework_payload=%s repo_root=%s sy "$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" "$FIXTURE_REPO_ROOT_COUNT" "$sync_store_count" "$runtime_link_count" if [[ ! -s "$declared_set" ]]; then phase_fail P4 "NOT-MEASURED / UNDECLARED: installer published no checkout-free, lane/versioned shipped-set artifact at $declared_set" -elif node - "$declared_set" <<'NODE' +elif EXPECTED_LANE="$([[ "$lane" == next ]] && echo next || echo latest)" EXPECTED_VERSION="$resolved_version" \ + MOSAIC_SKILLS_ROOT="$mosaic_home/skills" node - "$declared_set" <<'NODE' const fs = require('fs'); +const path = require('path'); const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); -if (!data || typeof data !== 'object' || !['latest', 'next'].includes(data.lane) || - typeof data.version !== 'string' || !data.version || !Array.isArray(data.skills) || data.skills.length === 0 || - data.skills.some((name) => typeof name !== 'string' || !name)) process.exit(1); +const root = path.resolve(process.env.MOSAIC_SKILLS_ROOT); +if (!data || data.lane !== process.env.EXPECTED_LANE || data.version !== process.env.EXPECTED_VERSION || + !Array.isArray(data.skills) || data.skills.length === 0) process.exit(1); +for (const name of data.skills) { + if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) process.exit(1); + const skill = path.join(root, name, 'SKILL.md'); + let real; + try { real = fs.realpathSync(skill); } catch { process.exit(1); } + const text = fs.readFileSync(real, 'utf8'); + const declaredName = text.match(/^---\s*$[\s\S]*?^name:\s*([^\s]+)\s*$/m)?.[1]; + if (!real.startsWith(root + path.sep) || !fs.statSync(real).isFile() || !text || declaredName !== name) process.exit(1); +} NODE then declared_count="$(node -p "require('$declared_set').skills.length")" - phase_pass P4 "declared shipped-set artifact parses (declared_count=$declared_count); C5 owns containment/loadability fulfillment" + if [[ -s "$mosaic_home/.install-manifest.json" ]] \ + && [[ "$(node -p "require('$mosaic_home/.install-manifest.json').phaseOutcomes?.P4 || 'committed'")" == failed ]]; then + phase_fail P4 "declared skills are present but the required framework/skills action reported failure" + else + phase_pass P4 "declared shipped-set matches lane/version and all $declared_count skill(s) are contained and loadable" + fi else - phase_fail P4 "NOT-MEASURED / UNDECLARED: shipped-set artifact exists but is empty, malformed, or lacks lane/version" + phase_fail P4 "shipped-set artifact is malformed, wrong-lane/version, or its declared skills are not contained and loadable" fi # P5 Identity @@ -207,13 +248,20 @@ else fi # P6 Runtime linking / activation. #869 must remain unwired without its broker. +manifest="$mosaic_home/.install-manifest.json" broker_present=false [[ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/mosaic-lease/broker.sock" ]] && broker_present=true dead_hooks=0 if [[ -f "$home/.claude/settings.json" ]]; then dead_hooks="$(grep -Ec 'mutator-gate\.py|receipt-observer-client\.py' "$home/.claude/settings.json" || true)" fi -if [[ "$broker_present" == false && "$dead_hooks" -eq 0 ]]; then +p6_action_failed=false +if [[ -s "$manifest" ]]; then + p6_action_failed="$(node -p "require('$manifest').phaseOutcomes?.P6 === 'failed' ? 'true' : 'false'" 2>/dev/null || echo true)" +fi +if [[ "$p6_action_failed" == true ]]; then + phase_fail P6 "runtime linking/activation action reported a required failure" +elif [[ "$broker_present" == false && "$dead_hooks" -eq 0 ]]; then phase_pass P6 "broker absent and #869 enforcement hooks remain inactive" elif [[ "$broker_present" == true ]]; then phase_pass P6 "activation broker present; hook state is evaluable" @@ -257,19 +305,56 @@ exec runuser -u mosaic -- env -i \ FIXTURE_GIT_MODE="$FIXTURE_GIT_MODE" \ FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" \ FIXTURE_REPO_ROOT_COUNT="$FIXTURE_REPO_ROOT_COUNT" \ + MOSAIC_INSTALL_LOCAL_SOURCE_ARCHIVE="$([[ "$FIXTURE_SOURCE" == "checkout" ]] && echo /tmp/source-checkout.tar.gz)" \ + MOSAIC_INSTALL_LOCAL_SOURCE_COMMIT="$FIXTURE_CHECKOUT_CONTENT_ID" \ + MOSAIC_INSTALL_LOCAL_SOURCE_SHA256="$FIXTURE_CHECKOUT_SHA256" \ /bin/bash /tmp/run-as-target.sh INNER +if [[ "$SOURCE" == "checkout" ]]; then + { + printf '\n__MOSAIC_CHECKOUT_ARCHIVE__\n' + base64 "$checkout_archive" + } >> "$inner" +fi chmod 0755 "$inner" printf '[fixture] platform=Debian/glibc image=%s target_uid=1001 lane=%s source=%s git=%s\n' "$IMAGE" "$LANE" "$SOURCE" "$GIT_MODE" printf '[fixture] host inheritance: no bind mounts, no host HOME, no npm cache, no credentials\n' -docker run --rm -i \ - --network bridge \ - --env FIXTURE_LANE="$LANE" \ - --env FIXTURE_SOURCE="$SOURCE" \ - --env FIXTURE_GIT_MODE="$GIT_MODE" \ - --env FIXTURE_INSTALLER_B64="$installer_b64" \ - --env FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \ - --env FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \ - "$IMAGE" /bin/bash -s < "$inner" +if [[ "$IN_CLEAN_CONTAINER" == "1" ]]; then + # Woodpecker already supplies the clean Debian container. The target install + # still runs through runuser + env -i, so CI variables/credentials do not + # enter the target user's process. + FIXTURE_LANE="$LANE" \ + FIXTURE_SOURCE="$SOURCE" \ + FIXTURE_GIT_MODE="$GIT_MODE" \ + FIXTURE_INSTALLER_B64="$installer_b64" \ + FIXTURE_CHECKOUT_SHA256="$checkout_digest" \ + FIXTURE_CHECKOUT_CONTENT_ID="$checkout_content_id" \ + FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \ + FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \ + /bin/bash "$inner" +else + # Copy the self-contained script+archive into a stopped container instead of + # bind-mounting the checkout or passing host paths. The target runtime still + # inherits no host HOME/cache/credentials, and the multi-megabyte checkout + # payload avoids argv/environment size limits. + fixture_cid="$(docker create \ + --network bridge \ + --env FIXTURE_LANE="$LANE" \ + --env FIXTURE_SOURCE="$SOURCE" \ + --env FIXTURE_GIT_MODE="$GIT_MODE" \ + --env FIXTURE_INSTALLER_B64="$installer_b64" \ + --env FIXTURE_CHECKOUT_SHA256="$checkout_digest" \ + --env FIXTURE_CHECKOUT_CONTENT_ID="$checkout_content_id" \ + --env FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \ + --env FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \ + "$IMAGE" /bin/bash /tmp/mosaic-greenfield-fixture.sh)" + docker cp "$inner" "$fixture_cid:/tmp/mosaic-greenfield-fixture.sh" + set +e + docker start -a "$fixture_cid" + fixture_status=$? + set -e + docker rm "$fixture_cid" >/dev/null + exit "$fixture_status" +fi diff --git a/tools/install-next-lane.test.sh b/tools/install-next-lane.test.sh index 4dee1340..a4fb1735 100755 --- a/tools/install-next-lane.test.sh +++ b/tools/install-next-lane.test.sh @@ -7,8 +7,8 @@ trap 'rm -rf "$TMP"' EXIT FAKE_BIN="$TMP/bin" HOME_DIR="$TMP/home" -PREFIX="$TMP/prefix" -MOSAIC_HOME="$TMP/mosaic" +PREFIX="$HOME_DIR/prefix" +MOSAIC_HOME="$HOME_DIR/mosaic" STATE="$TMP/state" LOG="$TMP/npm.log" mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE" @@ -20,7 +20,27 @@ LOG="${MOSAIC_TEST_NPM_LOG:?}" STATE="${MOSAIC_TEST_STATE:?}" echo "$*" >> "$LOG" +if [[ "${1:-}" == "--version" ]]; then + echo "10.6.2" + exit 0 +fi + +install_cli() { + local version="$1" + echo "$version" > "$STATE/mosaic" + mkdir -p "${MOSAIC_PREFIX:?}/bin" + cat > "$MOSAIC_PREFIX/bin/mosaic" <&2 + exit 1 + fi 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}" ;; @@ -33,7 +53,7 @@ fi if [[ "$1" == "install" ]]; then case "$*" in *"@mosaicstack/mosaic@0.0.49-next.999"*) - echo "0.0.49-next.999" > "$STATE/mosaic" + install_cli "0.0.49-next.999" ;; *"@mosaicstack/gateway@0.0.7-next.999"*) if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then @@ -43,7 +63,7 @@ if [[ "$1" == "install" ]]; then echo "0.0.7-next.999" > "$STATE/gateway" ;; *"mosaicstack-mosaic-0.0.0-source.tgz"*) - echo "0.0.0-source" > "$STATE/mosaic" + install_cli "0.0.0-source" ;; *"mosaicstack-gateway-0.0.0-source.tgz"*) echo "0.0.0-source" > "$STATE/gateway" @@ -75,21 +95,48 @@ 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 +headers=""; output=""; url="" +while [[ $# -gt 0 ]]; do + case "$1" in + -D) headers="$2"; shift 2 ;; + -o) output="$2"; shift 2 ;; + --max-filesize) shift 2 ;; + -*) shift ;; + *) url="$1"; shift ;; + esac +done +case "$url" in + */api/v1/repos/mosaicstack/stack/commits?sha=*) + printf 'HTTP/1.1 200 OK\r\ncontent-type: application/json; charset=utf-8\r\n\r\n' > "$headers" + printf '[{"sha":"1111111111111111111111111111111111111111"}]\n' > "$output" + ;; + */archive/*.tar.gz) + if [[ "${MOSAIC_TEST_CORRUPT_ARCHIVE:-0}" == "1" ]]; then + printf 'not-a-tarball\n' > "$output" + else + archive_root="$(mktemp -d)" + mkdir -p "$archive_root/stack" + printf 'fixture\n' > "$archive_root/stack/.fixture" + /bin/tar czf "$output" -C "$archive_root" stack + rm -rf "$archive_root" + fi + ;; +esac FAKE_CURL chmod +x "$FAKE_BIN/curl" cat > "$FAKE_BIN/tar" <<'FAKE_TAR' #!/usr/bin/env bash set -euo pipefail -dest="" +dest=""; list=false while [[ $# -gt 0 ]]; do case "$1" in -C) dest="$2"; shift 2 ;; + -*t*|t*) list=true; shift ;; *) shift ;; esac done +[[ "$list" == true ]] && exit 0 if [[ -z "$dest" ]]; then echo "fake tar missing -C destination" >&2 exit 1 @@ -125,7 +172,12 @@ if [[ "$1" == "pack" ]]; then exit 0 fi -# install/build commands are no-ops in this harness. +if [[ "${MOSAIC_TEST_FAIL_PNPM_INSTALL:-0}" == "1" && "$1" == "install" ]]; then + echo "forced pnpm install failure" >&2 + exit 42 +fi + +# Other install/build commands are no-ops in this harness. exit 0 FAKE_PNPM chmod +x "$FAKE_BIN/pnpm" @@ -135,6 +187,15 @@ reset_state() { rm -f "$STATE"/* } +prefix_fingerprint() { + if [[ ! -d "$PREFIX" ]]; then printf 'ABSENT\n'; return; fi + ( + cd "$PREFIX" + find . -mindepth 1 -printf '%P|%y|%m|%l\n' | LC_ALL=C sort + find . -type f -print0 | LC_ALL=C sort -z | xargs -0 -r sha256sum + ) | sha256sum | awk '{print $1}' +} + reset_state echo "[test] --next fast path pins resolved package versions" OUTPUT="$( @@ -155,11 +216,18 @@ 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 +if grep -qF 'Downloading source ref next at pinned commit' <<<"$OUTPUT"; then echo "fast path unexpectedly fell back to source" >&2 exit 1 fi +ACTIVE="$HOME_DIR/.local/state/mosaic/install/active.json" +[[ "$(node -p "require('$ACTIVE').status")" == "committed" ]] +JOURNAL="$(node -p "require('$ACTIVE').journal")" +[[ "$(stat -c '%a' "$JOURNAL")" == "444" ]] +( cd "$(dirname "$JOURNAL")" && sha256sum -c "$(basename "$JOURNAL").sha256" >/dev/null ) +grep -q '"event":"mutation".*"phase":"P3".*path=.*prior=.*reverse=' "$JOURNAL" + reset_state echo "[test] fast path failure falls back to source build" OUTPUT="$( @@ -176,7 +244,7 @@ OUTPUT="$( 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 'Downloading source ref next at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT" grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT" grep -qF 'install -g @mosaicstack/mosaic@0.0.49-next.999' "$LOG" grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG" @@ -184,8 +252,72 @@ 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] source-build failure is fatal and restores the pre-install prefix" +before_prefix="$(prefix_fingerprint)" +set +e +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 \ + MOSAIC_TEST_FAIL_PNPM_INSTALL=1 \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1 +)" +FAIL_STATUS=$? +set -e +[[ "$FAIL_STATUS" -ne 0 ]] +[[ "$(prefix_fingerprint)" == "$before_prefix" ]] +grep -qF 'forced pnpm install failure' <<<"$OUTPUT" +[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]] + +reset_state +echo "[test] corrupt source archive is fatal and restores the pre-install prefix" +before_prefix="$(prefix_fingerprint)" +set +e +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 \ + MOSAIC_TEST_CORRUPT_ARCHIVE=1 \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1 +)" +FAIL_STATUS=$? +set -e +[[ "$FAIL_STATUS" -ne 0 ]] +[[ "$(prefix_fingerprint)" == "$before_prefix" ]] +grep -qF 'archive safety/integrity check failed' <<<"$OUTPUT" +[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]] + +reset_state +echo "[test] --dev source install does not require registry version resolution" +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_NPM_VIEW=1 \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --cli --dev --ref feature-x --yes --no-auto-launch +)" +grep -qF 'Downloading source ref feature-x at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT" +grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT" +grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT" + reset_state echo "[test] explicit --ref keeps source lane and avoids @next lookup" +set +e OUTPUT="$( HOME="$HOME_DIR" \ MOSAIC_HOME="$MOSAIC_HOME" \ @@ -196,15 +328,18 @@ OUTPUT="$( PATH="$FAKE_BIN:$PATH" \ bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x )" - -grep -qF 'explicit ref wins, build-from-source' <<<"$OUTPUT" +CHECK_STATUS=$? +set -e +[[ "$CHECK_STATUS" -ne 0 ]] +grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$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" +echo "[test] --check --next rejects mismatched prerelease pipeline suffixes" +set +e OUTPUT="$( HOME="$HOME_DIR" \ MOSAIC_HOME="$MOSAIC_HOME" \ @@ -216,7 +351,9 @@ OUTPUT="$( 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" +CHECK_STATUS=$? +set -e +[[ "$CHECK_STATUS" -ne 0 ]] +grep -q '^\[P2\] FAIL: resolved_version=unavailable' <<<"$OUTPUT" echo "[test] installer next lane tests passed" diff --git a/tools/install-state-machine.test.sh b/tools/install-state-machine.test.sh index cd6b85b7..ae5dde26 100755 --- a/tools/install-state-machine.test.sh +++ b/tools/install-state-machine.test.sh @@ -2,6 +2,9 @@ # Red-first acceptance checks for #1050. This file is committed before the # installer implementation. Do not weaken these properties to make it green. +# pass_case always returns zero and fail_case records the aggregate failure; +# the compact A&&pass||fail assertions are intentional. +# shellcheck disable=SC2015 set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -26,8 +29,9 @@ make_fake_npm() { local bin="$1" mkdir -p "$bin" cat > "$bin/npm" <<'FAKE' -#!/usr/bin/env bash +#!/bin/bash set -euo pipefail +if [[ "${1:-}" == "--version" ]]; then echo '10.6.2'; exit 0; fi case "${1:-} ${2:-} ${3:-}" in 'view @mosaicstack/mosaic@next version') echo '0.0.50-next.999' ;; 'view @mosaicstack/gateway@next version') echo '0.0.7-next.999' ;; @@ -86,6 +90,78 @@ for phase in P5 P8; do || fail_case "$phase did not report its own expected failure" done +printf '[test] case: --check discriminates a constructed good host without mutation\n' +good_home="$TMP/good-home" +good_bin="$TMP/good-bin" +good_prefix="$good_home/.npm-global" +good_mosaic="$good_home/.config/mosaic" +mkdir -p "$good_bin" "$good_prefix/bin" "$good_mosaic/skills/declared-skill" +make_fake_npm "$good_bin" +cat > "$good_prefix/bin/mosaic" <<'CLI' +#!/usr/bin/env bash +printf '0.0.50-next.999\n' +CLI +chmod 0755 "$good_prefix/bin/mosaic" +cat > "$good_bin/getent" < "$good_bin/bash" < "$good_mosaic/SOUL.md" +printf '# User\n\nConfigured.\n' > "$good_mosaic/USER.md" +chmod 0600 "$good_mosaic/SOUL.md" "$good_mosaic/USER.md" +cat > "$good_mosaic/skills/declared-skill/SKILL.md" <<'SKILL' +--- +name: declared-skill +description: Constructed loadable acceptance skill. +--- + +# Declared skill +SKILL +printf '{"lane":"next","version":"0.0.50-next.999","skills":["declared-skill"]}\n' > "$good_mosaic/.install-shipped-skills.json" +printf '{\n "lane": "next",\n "cliVersion": "0.0.50-next.999"\n}\n' > "$good_mosaic/.install-manifest.json" +before="$(fingerprint "$good_home")" +set +e +HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \ + MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --check --next >"$TMP/good-check.log" 2>&1 +status=$? +set -e +after="$(fingerprint "$good_home")" +[[ "$status" -eq 0 ]] && pass_case 'good-host --check exited zero' || fail_case "good-host --check exited $status" +[[ "$before" == "$after" ]] && pass_case 'good-host --check left HOME unchanged' || fail_case 'good-host --check mutated HOME' +good_rows="$(grep -Ec '^\[P[0-8]\] PASS:' "$TMP/good-check.log" || true)" +[[ "$good_rows" -eq 9 ]] && pass_case 'good-host --check emitted nine PASS rows' \ + || { cat "$TMP/good-check.log" >&2; fail_case "good-host --check emitted $good_rows PASS rows"; } + +printf '[test] case: persisted required-action failures remain blocking\n' +for blocked_phase in P4 P6; do + node -e ' + const fs=require("fs"); const p=process.argv[1]; const phase=process.argv[2]; + const m=JSON.parse(fs.readFileSync(p,"utf8")); m.phaseOutcomes={P4:"committed",P6:"committed"}; + m.phaseOutcomes[phase]="failed"; fs.writeFileSync(p,JSON.stringify(m)+"\n"); + ' "$good_mosaic/.install-manifest.json" "$blocked_phase" + set +e + HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \ + MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --check --next >"$TMP/action-$blocked_phase.log" 2>&1 + status=$? + set -e + [[ "$status" -ne 0 ]] || fail_case "$blocked_phase action failure returned zero" + grep -q "^\[$blocked_phase\] FAIL:.*action reported a required $blocked_phase failure" "$TMP/action-$blocked_phase.log" \ + && pass_case "$blocked_phase action failure remained blocking in a later --check" \ + || fail_case "$blocked_phase persisted action failure was not attributed" +done +printf '{\n "lane": "next",\n "cliVersion": "0.0.50-next.999",\n "phaseOutcomes": {"P4":"committed","P6":"committed"}\n}\n' > "$good_mosaic/.install-manifest.json" + printf '[test] case: per-phase P2-P8 fault injection restores representative host mutations\n' for phase in P2 P3 P4 P5 P6 P7 P8; do home="$TMP/fault-$phase/home" @@ -117,6 +193,86 @@ for phase in P2 P3 P4 P5 P6 P7 P8; do fi done +printf '[test] case: unsafe and overlapping rollback roots fail before mutation\n' +unsafe_home="$TMP/unsafe-home" +mkdir -p "$unsafe_home" +for case_name in root-target home-target overlap-target; do + case "$case_name" in + root-target) unsafe_mosaic=/; unsafe_prefix="$unsafe_home/.npm-global" ;; + home-target) unsafe_mosaic="$unsafe_home"; unsafe_prefix="$unsafe_home/.npm-global" ;; + overlap-target) unsafe_mosaic="$unsafe_home/.config"; unsafe_prefix="$unsafe_home/.config/mosaic/prefix" ;; + esac + before="$(fingerprint "$unsafe_home")" + set +e + HOME="$unsafe_home" MOSAIC_HOME="$unsafe_mosaic" MOSAIC_PREFIX="$unsafe_prefix" \ + MOSAIC_NO_COLOR=1 PATH="$check_bin:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --check --next >"$TMP/$case_name.log" 2>&1 + status=$? + set -e + after="$(fingerprint "$unsafe_home")" + [[ "$status" -ne 0 ]] || fail_case "$case_name unsafe path returned zero" + grep -q '^\[P0\] FAIL:.*unsafe context' "$TMP/$case_name.log" \ + && pass_case "$case_name was rejected by P0" || fail_case "$case_name lacked an attributable P0 failure" + [[ "$before" == "$after" ]] || fail_case "$case_name mutated HOME" +done + +symlink_home="$TMP/symlink-home" +symlink_outside="$TMP/symlink-outside" +mkdir -p "$symlink_home" "$symlink_outside" +ln -s "$symlink_outside" "$symlink_home/.config" +set +e +HOME="$symlink_home" MOSAIC_HOME="$symlink_home/.config/mosaic" MOSAIC_PREFIX="$symlink_home/.npm-global" \ + MOSAIC_NO_COLOR=1 PATH="$check_bin:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --check --next >"$TMP/symlink-target.log" 2>&1 +status=$? +set -e +[[ "$status" -ne 0 ]] || fail_case 'symlink-parent unsafe path returned zero' +grep -q '^\[P0\] FAIL:.*unsafe context' "$TMP/symlink-target.log" \ + && pass_case 'symlinked rollback parent was rejected by P0' \ + || fail_case 'symlinked rollback parent lacked an attributable P0 failure' +[[ -z "$(find "$symlink_outside" -mindepth 1 -print -quit)" ]] || fail_case 'symlink target was mutated' + +printf '[test] case: stale in-progress projection does not impersonate a live OS lock\n' +stale_home="$TMP/stale/home" +stale_state="$TMP/stale/state" +mkdir -p "$stale_home/.config/mosaic" "$stale_state" +printf '{"status":"in-progress","journal":"%s"}\n' "$stale_state/dead-run/journal.ndjson" > "$stale_state/active.json" +set +e +HOME="$stale_home" MOSAIC_HOME="$stale_home/.config/mosaic" MOSAIC_PREFIX="$stale_home/.npm-global" \ + MOSAIC_INSTALL_STATE_DIR="$stale_state" MOSAIC_INSTALL_FAULT_AFTER=P2 MOSAIC_NO_COLOR=1 \ + bash "$ROOT/tools/install.sh" --state-machine-self-test >"$TMP/stale.log" 2>&1 +status=$? +set -e +[[ "$status" -eq 97 ]] || fail_case "stale projection recovery expected injected status 97, got $status" +if find "$stale_state" -name prior-active.json -type f -print -quit | grep -q .; then + pass_case 'stale projection was preserved and superseded after the free OS lock was acquired' +else + fail_case 'stale projection was not preserved for recovery evidence' +fi +[[ "$(node -p "require('$stale_state/active.json').status")" == "rolled-back" ]] \ + || fail_case 'stale retry did not reach an honest rolled-back terminal state' + +printf '[test] case: journal initialization failure is fatal before mutation\n' +journal_home="$TMP/journal-failure/home" +mkdir -p "$journal_home/.config/mosaic" +printf 'journal-sentinel\n' > "$journal_home/.config/mosaic/operator.txt" +before="$(fingerprint "$journal_home")" +set +e +HOME="$journal_home" MOSAIC_HOME="$journal_home/.config/mosaic" MOSAIC_PREFIX="$journal_home/.npm-global" \ + MOSAIC_INSTALL_STATE_DIR="/proc/mosaic-journal-denied-$$" MOSAIC_INSTALL_FAULT_AFTER=P2 \ + MOSAIC_NO_COLOR=1 bash "$ROOT/tools/install.sh" --state-machine-self-test \ + >"$TMP/journal-failure.log" 2>&1 +status=$? +set -e +after="$(fingerprint "$journal_home")" +[[ "$status" -ne 0 ]] && pass_case 'unwritable journal directory failed non-zero' \ + || fail_case 'unwritable journal directory returned zero' +grep -q 'cannot create private journal directory' "$TMP/journal-failure.log" \ + && pass_case 'journal initialization failure was named' \ + || fail_case 'journal initialization failure lacked a named diagnostic' +[[ "$before" == "$after" ]] && pass_case 'journal failure occurred before target mutation' \ + || fail_case "journal failure mutated target HOME (before=$before after=$after)" + if [[ "$failures" -ne 0 ]]; then printf '[test] install state-machine acceptance RED: %d failed assertion(s)\n' "$failures" >&2 printf '[test] --check transcript: %s\n' "$TMP/check.log" >&2 diff --git a/tools/install.sh b/tools/install.sh index 96c9ad49..79121edb 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -55,6 +55,7 @@ FLAG_YES=false FLAG_UNINSTALL=false FLAG_DEV=false FLAG_NEXT=false +FLAG_STATE_SELF_TEST=false GIT_REF="${MOSAIC_REF:-main}" GIT_REF_EXPLICIT=false if [[ -n "${MOSAIC_REF:-}" ]]; then @@ -110,6 +111,9 @@ while [[ $# -gt 0 ]]; do --yes|-y) FLAG_YES=true; shift ;; --no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;; --uninstall) FLAG_UNINSTALL=true; shift ;; + # Internal acceptance seam: exercises the real journal/snapshot/rollback + # machinery against representative installer mutations. Not a user mode. + --state-machine-self-test) FLAG_STATE_SELF_TEST=true; shift ;; *) printf 'Error: Unknown argument: %s\n' "$1" >&2 installer_usage @@ -305,44 +309,44 @@ require_cmd() { if ! command -v "$1" &>/dev/null; then fail "Required command not found: $1" echo " Install it and re-run this script." - exit 1 + return 1 fi } installed_cli_version() { local json - json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true + json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" --cache="${STATE_NPM_CACHE:-${TMPDIR:-/tmp}/mosaic-install-npm-cache-$$}")" || true if [[ -n "$json" ]]; then node -e " const d = JSON.parse(process.argv[1]); const v = d?.dependencies?.['${CLI_PKG}']?.version ?? ''; process.stdout.write(v); - " "$json" 2>/dev/null || true + " "$json" || true fi } installed_gateway_version() { local json - json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true + json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" --cache="${STATE_NPM_CACHE:-${TMPDIR:-/tmp}/mosaic-install-npm-cache-$$}")" || 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 + " "$json" || true fi } latest_cli_version() { - npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true + npm view "${CLI_PKG}" version --registry="$REGISTRY" --cache="${STATE_NPM_CACHE:-${TMPDIR:-/tmp}/mosaic-install-npm-cache-$$}" || true } next_cli_version() { - npm view "${CLI_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true + npm view "${CLI_PKG}@next" version --registry="$REGISTRY" --cache="${STATE_NPM_CACHE:-${TMPDIR:-/tmp}/mosaic-install-npm-cache-$$}" || true } next_gateway_version() { - npm view "${GATEWAY_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true + npm view "${GATEWAY_PKG}@next" version --registry="$REGISTRY" --cache="${STATE_NPM_CACHE:-${TMPDIR:-/tmp}/mosaic-install-npm-cache-$$}" || true } next_pipeline_suffix() { @@ -380,37 +384,760 @@ framework_version() { fi } -# Download + extract the monorepo archive at $GIT_REF exactly once per run. -# Sets the script-level EXTRACTED_DIR to the repo root. Reused by both the -# framework install (Part 1) and the dev build-from-source path (Part 2). +# ─── Transactional install state (canonical P0-P9) ─────────────────────────── +# The phase numbering and names are an external contract. C2-C5 bind to these +# exact numbers, so do not renumber when filling a failed postcondition. +INSTALL_PHASES=(P0 P1 P2 P3 P4 P5 P6 P7 P8 P9) +STATE_DIR="${MOSAIC_INSTALL_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/mosaic/install}" +STATE_RUN_DIR="" +STATE_JOURNAL="" +STATE_COMMAND_LOG="" +STATE_SNAPSHOT_DIR="" +STATE_FRAMEWORK_STATUS="" +STATE_INTERRUPTED_ACTIVE="" +STATE_CURRENT_PHASE="P0" +STATE_LOCK_FD="" +STATE_FAILURES=0 +STATE_FAILED_PHASES=() +STATE_NPM_CACHE="${TMPDIR:-/tmp}/mosaic-install-npm-cache-$$" +RESOLVED_CLI_VERSION="" +RESOLVED_SOURCE_DIGEST="" +LOCAL_SOURCE_ARCHIVE="${MOSAIC_INSTALL_LOCAL_SOURCE_ARCHIVE:-}" +LOCAL_SOURCE_COMMIT="${MOSAIC_INSTALL_LOCAL_SOURCE_COMMIT:-}" +LOCAL_SOURCE_SHA256="${MOSAIC_INSTALL_LOCAL_SOURCE_SHA256:-}" + +phase_name() { + case "$1" in + P0) echo "Resolve context" ;; P1) echo "Preflight" ;; + P2) echo "Acquire artifacts" ;; P3) echo "Install CLI" ;; + P4) echo "Install framework + skills" ;; P5) echo "Identity" ;; + P6) echo "Runtime linking / activation" ;; P7) echo "Services" ;; + P8) echo "Shell discoverability" ;; P9) echo "Verify + commit" ;; + *) echo "unknown" ;; + esac +} + +phase_contract() { + case "$1" in + P0) printf 'pre=target context available; action=resolve user/HOME/shell/platform; post=context stated and supported; rollback=n/a' ;; + P1) printf 'pre=P0 supported; action=validate tools/registry/headroom and acquire lock; post=preflight complete and exclusive; rollback=release lock' ;; + P2) printf 'pre=P1 exclusive; action=fetch pinned installer-distribution artifacts with visible output; post=lane/version/digest recorded; rollback=discard temporary artifacts; seam=does not forbid credentialed downstream acquisition' ;; + P3) printf 'pre=P2 pinned CLI; action=install CLI at known prefix; post=absolute binary version equals resolved version; rollback=restore prior prefix' ;; + P4) printf 'pre=P2 framework source and P3 absolute CLI; action=sync framework and skills; post=repository-shipped skills installed and loadable; rollback=restore prior framework/runtime trees' ;; + P5) printf 'pre=P3 absolute CLI; action=establish configured identity and validate any credential capability requested downstream; post=SOUL/USER valid owner/mode and required credential usable; rollback=remove generated identity/credential binding' ;; + P6) printf 'pre=P3 absolute CLI; action=evaluate runtime activation; post=dead #869 hooks never active without broker; rollback=restore runtime assets' ;; + P7) printf 'pre=P6 activation evaluated and applicable P5 credential committed; action=provision/manage requested services and credentialed resources only; post=requested services/resources ready; rollback=stop and restore requested services/resources' ;; + P8) printf 'pre=P3 absolute CLI; action=verify fresh target-user shells; post=login and non-login resolve P3 path; rollback=restore shell profiles' ;; + P9) printf 'pre=P0-P8 evaluated; action=reassert and commit journal/manifest; post=all phases pass and journal committed; rollback=restore pre-install snapshot' ;; + esac +} + +state_json_line() { + local event="$1" phase="$2" status="$3" message="$4" + [[ -n "$STATE_JOURNAL" ]] || return 0 + if ! EVENT="$event" PHASE="$phase" STATUS="$status" MESSAGE="$message" \ + node -e ' + const row={timestamp:new Date().toISOString(),event:process.env.EVENT,phase:process.env.PHASE,status:process.env.STATUS,message:process.env.MESSAGE}; + process.stdout.write(JSON.stringify(row)+"\\n"); + ' >> "$STATE_JOURNAL"; then + fail "Journal write failed at phase ${phase}; refusing an unrecorded mutation." + return 1 + fi + if ! sync "$STATE_JOURNAL"; then + fail "Journal sync failed at phase ${phase}; refusing an unrecorded mutation." + return 1 + fi +} + +state_record_mutation() { + local phase="$1" path="$2" reverse="$3" status root key covered=false + local prior="absent" snapshot="none" + if [[ -n "$STATE_SNAPSHOT_DIR" && -s "$STATE_SNAPSHOT_DIR/paths.tsv" ]]; then + while IFS=$'\t' read -r status root key; do + if [[ "$path" == "$root" || "$path" == "$root"/* ]]; then + covered=true + [[ -e "$path" || -L "$path" ]] && prior="present" + snapshot="$STATE_SNAPSHOT_DIR/data/$key" + break + fi + done < "$STATE_SNAPSHOT_DIR/paths.tsv" + fi + if [[ "$covered" != true && ( -e "$path" || -L "$path" ) ]]; then + # A path outside the declared snapshot cannot be mutated safely. + fail "Journal cannot bind prior state for $path before $phase mutation." + return 1 + fi + state_json_line mutation "$phase" planned "path=$path prior=$prior snapshot=$snapshot reverse=$reverse" +} + +state_seal_journal() { + local digest + state_json_line seal P9 committed "journal closed after manifest commit" || return + digest="$(sha256sum "$STATE_JOURNAL" | awk '{print $1}')" || return + if ! printf '%s %s\n' "$digest" "$(basename "$STATE_JOURNAL")" > "$STATE_JOURNAL.sha256" \ + || ! sync "$STATE_JOURNAL.sha256"; then + fail "Could not durably write the P9 journal seal." + return 1 + fi + if ! chmod 0444 "$STATE_JOURNAL" "$STATE_JOURNAL.sha256"; then + fail "Could not make the committed journal and seal immutable." + return 1 + fi + printf '%s' "$digest" +} + +state_framework_action_failed() { + local phase="$1" + [[ -n "$STATE_FRAMEWORK_STATUS" && -s "$STATE_FRAMEWORK_STATUS" ]] || return 1 + grep -q "^${phase}"$'\t'"failed"$'\t' "$STATE_FRAMEWORK_STATUS" +} + +state_manifest_action_failed() { + local phase="$1" manifest="$MOSAIC_HOME/.install-manifest.json" + [[ -s "$manifest" ]] || return 1 + node -e ' + const fs=require("fs"); + const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); + process.exit(data?.phaseOutcomes?.[process.argv[2]] === "failed" ? 0 : 1); + ' "$manifest" "$phase" 2>/dev/null +} + +state_action_failed() { + if [[ -n "$STATE_FRAMEWORK_STATUS" ]]; then + state_framework_action_failed "$1" + else + state_manifest_action_failed "$1" + fi +} + +state_run_captured() { + local label="$1" output status=0 + shift + output="$(mktemp "${TMPDIR:-/tmp}/mosaic-phase-command.XXXXXX.log")" || return + # The command is deliberately called in a conditional so its status can be + # journaled before the caller's ERR trap rolls back. Bash disables errexit in + # functions invoked this way, so every multi-command phase helper below must + # explicitly return on each required command failure. + if "$@" >"$output" 2>&1; then status=0; else status=$?; fi + cat "$output" || { rm -f "$output"; return 1; } + if ! { printf '\n=== %s (exit=%s) ===\n' "$label" "$status"; cat "$output"; } >> "$STATE_COMMAND_LOG"; then + rm -f "$output" + fail "Could not append '$label' output to $STATE_COMMAND_LOG; refusing to continue." + return 1 + fi + if ! sync "$STATE_COMMAND_LOG"; then + rm -f "$output" + fail "Could not sync '$label' output in $STATE_COMMAND_LOG; refusing to continue." + return 1 + fi + rm -f "$output" + state_json_line command "$STATE_CURRENT_PHASE" "$([[ "$status" -eq 0 ]] && echo committed || echo failed)" "label=$label output_log=$STATE_COMMAND_LOG exit=$status" + return "$status" +} + +state_write_active() { + local content="$1" + if ! printf '%s\n' "$content" > "$STATE_DIR/active.json" || ! sync "$STATE_DIR/active.json"; then + fail "Journal state write failed at $STATE_DIR/active.json; refusing to continue." + return 1 + fi +} + +state_phase_begin() { + STATE_CURRENT_PHASE="$1" + state_json_line phase "$1" started "$(phase_contract "$1")" +} + +state_phase_finish() { + state_json_line phase "$1" "$2" "$3" +} + +state_emit() { + local phase="$1" verdict="$2" reason="$3" + printf '[%s] %s: %s\n' "$phase" "$verdict" "$reason" + if [[ "$verdict" == "FAIL" ]]; then + STATE_FAILURES=$((STATE_FAILURES + 1)) + STATE_FAILED_PHASES+=("$phase") + fi +} + +state_target_shell() { + local shell="" + if command -v getent >/dev/null 2>&1; then + shell="$(getent passwd "$(id -u)" 2>/dev/null | cut -d: -f7 || true)" + fi + printf '%s' "${shell:-${SHELL:-}}" +} + +state_resolved_version() { + local cli gateway + if [[ "$FLAG_DEV" == "true" ]]; then + return 0 + fi + if is_next_registry_lane; then + cli="$(next_cli_version)" + gateway="$(next_gateway_version)" + [[ -n "$cli" && -n "$gateway" ]] && next_versions_share_pipeline "$cli" "$gateway" || return 0 + printf '%s' "$cli" + else + latest_cli_version + fi +} + +state_expected_cli_version() { + if [[ -n "$RESOLVED_CLI_VERSION" ]]; then + printf '%s' "$RESOLVED_CLI_VERSION" + elif [[ "$FLAG_DEV" == "true" && -s "$MOSAIC_HOME/.install-manifest.json" ]]; then + node -p "require('$MOSAIC_HOME/.install-manifest.json').cliVersion || ''" 2>/dev/null || true + else + state_resolved_version + fi +} + +state_predicate() { + local phase="$1" shell node_major installed expected + local missing=() login_path nonlogin_path broker=false dead_hooks=0 + local prefix_parent disk_kb inode_count min_disk_kb min_inodes npm_major privilege_mode + STATE_REASON="" + case "$phase" in + P0) + shell="$(state_target_shell)" + node_major="$(node -p 'Number(process.versions.node.split(".")[0])' 2>/dev/null || echo 0)" + npm_major="$(npm --version 2>/dev/null | cut -d. -f1 || echo 0)" + privilege_mode="$([[ "$(id -u)" -eq 0 ]] && echo root-without-explicit-target || echo user)" + if [[ -n "$HOME" && -n "$shell" && "$privilege_mode" == "user" && "$(uname -s)" == "Linux" ]] \ + && ldd --version 2>&1 | grep -qi 'glibc\|gnu libc' \ + && [[ "$(uname -m)" == "x86_64" ]] && [[ "$node_major" -ge 20 ]] && [[ "$npm_major" -ge 9 ]] \ + && state_validate_target_paths; then + STATE_REASON="target=$(id -un) uid=$(id -u) HOME=$HOME shell=$shell privilege=$privilege_mode arch=x86_64 libc=glibc node=$(node --version) npm=$(npm --version)" + return 0 + fi + STATE_REASON="unsupported, unresolved, or unsafe context (target=$(id -un 2>/dev/null || echo unknown) uid=$(id -u) HOME=${HOME:-unset} shell=${shell:-unset} privilege=$privilege_mode arch=$(uname -m 2>/dev/null || echo unknown) node_major=$node_major npm_major=$npm_major path_check=${STATE_PATH_REASON:-not-reached})" + return 1 + ;; + P1) + # Include tools invoked by downstream phases. Omitting git made P1 pass + # while P4's sync was already guaranteed to fail and be suppressed. + for tool in awk bash curl date df find flock git grep install mktemp node npm python3 realpath sed sha256sum stat sync tar; do + command -v "$tool" >/dev/null 2>&1 || missing+=("$tool") + done + if [[ "$FLAG_DEV" == "true" ]] && ! command -v corepack >/dev/null 2>&1; then + missing+=("corepack") + fi + # Concurrency authority is the OS-backed flock acquired by + # state_begin_install. active.json is a crash-recovery projection only; + # treating a stale in-progress projection as a live lock permanently + # blocked retries after SIGKILL or power loss. + if [[ -z "$STATE_LOCK_FD" && -f "$STATE_DIR/install.lock" ]]; then + local probe_lock_fd + if exec {probe_lock_fd}<>"$STATE_DIR/install.lock"; then + if ! flock -n "$probe_lock_fd"; then missing+=("concurrent-install-lock-held"); fi + exec {probe_lock_fd}>&- + else + missing+=("install-lock-unreadable") + fi + fi + prefix_parent="$(dirname "$PREFIX")" + [[ -d "$prefix_parent" && -w "$prefix_parent" ]] || missing+=("prefix-parent-not-writable") + min_disk_kb="${MOSAIC_INSTALL_MIN_DISK_KB:-262144}" + min_inodes="${MOSAIC_INSTALL_MIN_INODES:-1000}" + disk_kb="$(df -Pk "$prefix_parent" 2>&1 | awk 'NR==2 {print $4}')" + inode_count="$(df -Pi "$prefix_parent" 2>&1 | awk 'NR==2 {print $4}')" + [[ "$disk_kb" =~ ^[0-9]+$ && "$disk_kb" -ge "$min_disk_kb" ]] || missing+=("disk-headroom") + [[ "$inode_count" =~ ^[0-9]+$ && "$inode_count" -ge "$min_inodes" ]] || missing+=("inode-headroom") + if [[ "$FLAG_DEV" == "true" ]]; then + expected="source-build-at-immutable-ref" + else + expected="$(state_resolved_version)" + [[ -n "$expected" ]] || missing+=("registry-lane-unreachable-or-unauthenticated") + fi + if [[ "${#missing[@]}" -eq 0 ]]; then + STATE_REASON="downstream tool closure present; prefix parent writable; artifact lane resolvable; disk_kb=$disk_kb inodes=$inode_count; concurrency delegated to OS lock" + return 0 + fi + STATE_REASON="preflight failures: ${missing[*]}" + return 1 + ;; + P2) + if [[ "$FLAG_DEV" == "true" ]]; then + local source_commit="${RESOLVED_SOURCE_COMMIT:-}" source_digest="${RESOLVED_SOURCE_DIGEST:-}" + if [[ "$FLAG_CHECK" == "true" && -s "$MOSAIC_HOME/.install-manifest.json" ]]; then + source_commit="$(node -p "require('$MOSAIC_HOME/.install-manifest.json').sourceCommit || ''" 2>/dev/null || true)" + source_digest="$(node -p "require('$MOSAIC_HOME/.install-manifest.json').sourceSha256 || ''" 2>/dev/null || true)" + fi + if [[ "$source_commit" =~ ^[0-9a-f]{40}$ && "$source_digest" =~ ^[0-9a-f]{64}$ ]]; then + STATE_REASON="source_ref=$GIT_REF pinned_commit=$source_commit sha256=$source_digest" + return 0 + fi + STATE_REASON="source ref has no installed pinned commit/digest evidence (commit=${source_commit:-unavailable} sha256=${source_digest:-unavailable})" + return 1 + fi + expected="${RESOLVED_CLI_VERSION:-$(state_resolved_version)}" + if [[ -n "$expected" ]] && { [[ "$FLAG_CHECK" == "false" ]] || grep -qF "\"lane\": \"$([[ "$FLAG_NEXT" == true ]] && echo next || echo latest)\"" "$MOSAIC_HOME/.install-manifest.json" 2>/dev/null; }; then + STATE_REASON="lane=$([[ "$FLAG_NEXT" == true ]] && echo next || echo latest) pinned_version=$expected" + return 0 + fi + STATE_REASON="resolved_version=${expected:-unavailable}; installed manifest does not record the resolved lane" + return 1 + ;; + P3) + expected="$(state_expected_cli_version)" + installed="" + [[ -x "$PREFIX/bin/mosaic" ]] && installed="$("$PREFIX/bin/mosaic" --version 2>&1 | tail -n 1 | tr -d '\r' || true)" + if [[ -n "$expected" && -x "$PREFIX/bin/mosaic" && "$installed" == "$expected" ]]; then + STATE_REASON="absolute_path=$PREFIX/bin/mosaic version=$installed equals resolved lane version" + return 0 + fi + STATE_REASON="absolute_path=$PREFIX/bin/mosaic executable=$([[ -x "$PREFIX/bin/mosaic" ]] && echo yes || echo no) got=${installed:-missing} expected=${expected:-unresolved}" + return 1 + ;; + P4) + # C1 defines and enforces the assertion surface but does not choose among + # the four disagreeing candidate populations. C5 owns publishing and + # fulfilling the declaration. Until then P4 remains NOT-MEASURED. + local declared_set="$MOSAIC_HOME/.install-shipped-skills.json" + local expected_lane expected_version + expected_lane="$([[ "$FLAG_NEXT" == true ]] && echo next || echo latest)" + expected_version="$(state_expected_cli_version)" + if [[ ! -s "$declared_set" ]]; then + STATE_REASON="NOT-MEASURED / UNDECLARED: installer published no checkout-free, lane/versioned shipped-set artifact at $declared_set" + return 1 + fi + if ! EXPECTED_LANE="$expected_lane" EXPECTED_VERSION="$expected_version" MOSAIC_SKILLS_ROOT="$MOSAIC_HOME/skills" \ + node - "$declared_set" <<'NODE' +const fs = require('fs'); +const path = require('path'); +const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +const root = path.resolve(process.env.MOSAIC_SKILLS_ROOT); +if (!data || typeof data !== 'object' || data.lane !== process.env.EXPECTED_LANE || + data.version !== process.env.EXPECTED_VERSION || !Array.isArray(data.skills) || data.skills.length === 0) process.exit(1); +for (const name of data.skills) { + if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) process.exit(1); + const skill = path.join(root, name, 'SKILL.md'); + let real; + try { real = fs.realpathSync(skill); } catch { process.exit(1); } + if (!real.startsWith(root + path.sep)) process.exit(1); + const stat = fs.statSync(real); + const text = fs.readFileSync(real, 'utf8'); + const declaredName = text.match(/^---\s*$[\s\S]*?^name:\s*([^\s]+)\s*$/m)?.[1]; + if (!stat.isFile() || stat.size === 0 || declaredName !== name) process.exit(1); +} +NODE + then + STATE_REASON="declared shipped-set artifact is malformed, wrong-lane/version, or its declared skills are not contained and loadable" + return 1 + fi + if state_action_failed P4; then + STATE_REASON="framework/skills action reported a required P4 failure; inspect the transaction command log" + return 1 + fi + STATE_REASON="declared shipped-set matches lane=$expected_lane version=$expected_version; every declared skill is contained and loadable" + return 0 + ;; + P5) + for skill in SOUL.md USER.md; do + local path="$MOSAIC_HOME/$skill" + if [[ ! -s "$path" ]] || ! grep -q '^# ' "$path" 2>/dev/null \ + || [[ "$(stat -c '%u' "$path" 2>/dev/null || echo -1)" != "$(id -u)" ]] \ + || [[ "$(stat -c '%a' "$path" 2>/dev/null || echo 777)" =~ [2367]$ ]]; then + missing+=("$skill") + fi + done + if [[ "${#missing[@]}" -eq 0 ]]; then STATE_REASON="SOUL.md and USER.md parse and have target owner/mode"; return 0; fi + STATE_REASON="identity missing, empty, malformed, wrong-owner, or unsafe-mode: ${missing[*]}" + return 1 + ;; + P6) + if state_action_failed P6; then + STATE_REASON="runtime linking/activation action reported a required P6 failure; inspect the transaction command log" + return 1 + fi + [[ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/mosaic-lease/broker.sock" ]] && broker=true + if [[ -f "$HOME/.claude/settings.json" ]]; then + dead_hooks="$(grep -Ec 'mutator-gate\.py|receipt-observer-client\.py' "$HOME/.claude/settings.json" || true)" + fi + if [[ "$broker" == true || "$dead_hooks" -eq 0 ]]; then + STATE_REASON="$([[ "$broker" == true ]] && echo 'activation broker present' || echo 'broker absent and #869 hooks inactive')" + return 0 + fi + STATE_REASON="broker absent but dead #869 enforcement hooks active (count=$dead_hooks)" + return 1 + ;; + P7) + STATE_REASON="no services requested by this installer invocation" + return 0 + ;; + P8) + shell="$(state_target_shell)" + case "${shell##*/}" in + bash|zsh) + login_path="$(env -i HOME="$HOME" USER="$(id -un)" LOGNAME="$(id -un)" SHELL="$shell" PATH=/usr/local/bin:/usr/bin:/bin "$shell" -lc 'command -v mosaic' 2>&1 || true)" + nonlogin_path="$(env -i HOME="$HOME" USER="$(id -un)" LOGNAME="$(id -un)" SHELL="$shell" PATH=/usr/local/bin:/usr/bin:/bin "$shell" -c 'command -v mosaic' 2>&1 || true)" + ;; + fish) + login_path="$(env -i HOME="$HOME" USER="$(id -un)" LOGNAME="$(id -un)" SHELL="$shell" PATH=/usr/local/bin:/usr/bin:/bin "$shell" -lc 'command -v mosaic' 2>&1 || true)" + nonlogin_path="$(env -i HOME="$HOME" USER="$(id -un)" LOGNAME="$(id -un)" SHELL="$shell" PATH=/usr/local/bin:/usr/bin:/bin "$shell" -c 'command -v mosaic' 2>&1 || true)" + ;; + *) STATE_REASON="unsupported or unresolved target shell: ${shell:-unset}"; return 1 ;; + esac + if [[ "$login_path" == "$PREFIX/bin/mosaic" && "$nonlogin_path" == "$PREFIX/bin/mosaic" ]]; then + STATE_REASON="login=$login_path nonlogin=$nonlogin_path equals P3 path" + return 0 + fi + STATE_REASON="fresh ${shell##*/} login=${login_path:-missing} nonlogin=${nonlogin_path:-missing} expected=$PREFIX/bin/mosaic" + return 1 + ;; + esac +} + +state_check_all() { + local phase + STATE_FAILURES=0 + STATE_FAILED_PHASES=() + for phase in "${INSTALL_PHASES[@]:0:9}"; do + if state_predicate "$phase"; then state_emit "$phase" PASS "$STATE_REASON"; else state_emit "$phase" FAIL "$STATE_REASON"; fi + done + [[ "$STATE_FAILURES" -eq 0 ]] +} + +# Component-only installs preserve their historical narrow contract. `--check` +# is never narrowed: it always calls state_check_all above and evaluates P0-P8. +state_check_install_scope() { + local phase + STATE_FAILURES=0 + STATE_FAILED_PHASES=() + for phase in "${INSTALL_PHASES[@]:0:9}"; do + if [[ "$FLAG_CLI" == "true" && "$FLAG_FRAMEWORK" == "false" && "$phase" =~ ^P[4-8]$ ]]; then + state_emit "$phase" PASS "not requested by --cli component-only install" + continue + fi + if [[ "$FLAG_FRAMEWORK" == "true" && "$FLAG_CLI" == "false" && "$phase" == "P3" ]]; then + state_emit "$phase" PASS "not requested by --framework component-only install" + continue + fi + if state_predicate "$phase"; then state_emit "$phase" PASS "$STATE_REASON"; else state_emit "$phase" FAIL "$STATE_REASON"; fi + done + [[ "$STATE_FAILURES" -eq 0 ]] +} + +state_path_is_safe_target() { + local raw="$1" canonical_home normalized owner + canonical_home="$(realpath -e -- "$HOME" 2>/dev/null)" || return 1 + [[ "$HOME" == "$canonical_home" && "$raw" == /* && "$raw" != *$'\n'* ]] || return 1 + normalized="$(realpath -m -- "$raw" 2>/dev/null)" || return 1 + [[ "$normalized" == "$raw" && "$raw" != "$HOME" && "$raw" == "$HOME"/* ]] || return 1 + # realpath -m follows every existing symlink component. Equality therefore + # rejects a target or parent redirected outside the rollback tree. + if [[ -e "$raw" || -L "$raw" ]]; then + [[ ! -L "$raw" ]] || return 1 + owner="$(stat -c '%u' "$raw" 2>/dev/null)" || return 1 + [[ "$owner" == "$(id -u)" ]] || return 1 + fi +} + +state_validate_target_paths() { + local left right i j + local targets=( + "$MOSAIC_HOME" "$PREFIX" "$HOME/.npmrc" "$HOME/.bashrc" "$HOME/.bash_profile" + "$HOME/.profile" "$HOME/.zshrc" "$HOME/.config/fish/config.fish" "$HOME/.claude" + "$HOME/.pi" "$HOME/.codex" "$HOME/.config/opencode" "$HOME/.config/mosaic-gateway" + "$HOME/.config/systemd" "$HOME/.local/share/systemd" "$HOME/.local/state/mosaic-gateway" + "$HOME/.local/state/mosaic/backups" + ) + STATE_PATH_REASON="" + for left in "${targets[@]}"; do + if ! state_path_is_safe_target "$left"; then + STATE_PATH_REASON="unsafe rollback target: $left (must be a non-symlinked, target-user-owned strict descendant of canonical HOME=$HOME)" + return 1 + fi + done + for ((i=0; i<${#targets[@]}; i++)); do + for ((j=i+1; j<${#targets[@]}; j++)); do + left="${targets[$i]}"; right="${targets[$j]}" + if [[ "$left" == "$right" || "$left" == "$right"/* || "$right" == "$left"/* ]]; then + STATE_PATH_REASON="overlapping rollback targets are forbidden: $left and $right" + return 1 + fi + done + done +} + +state_snapshot_create() { + local dst list path key index=0 + if ! state_validate_target_paths; then + fail "P1 Preflight refused snapshot creation: $STATE_PATH_REASON" + return 1 + fi + STATE_SNAPSHOT_DIR="$STATE_RUN_DIR/snapshot" + mkdir -p "$STATE_SNAPSHOT_DIR/data" + list="$STATE_SNAPSHOT_DIR/paths.tsv" + : > "$list" + for path in "$MOSAIC_HOME" "$PREFIX" "$HOME/.npmrc" "$HOME/.bashrc" "$HOME/.bash_profile" \ + "$HOME/.profile" "$HOME/.zshrc" "$HOME/.config/fish/config.fish" "$HOME/.claude" \ + "$HOME/.pi" "$HOME/.codex" "$HOME/.config/opencode" "$HOME/.config/mosaic-gateway" \ + "$HOME/.config/systemd" "$HOME/.local/share/systemd" "$HOME/.local/state/mosaic-gateway" \ + "$HOME/.local/state/mosaic/backups"; do + key="path-$index" + index=$((index + 1)) + if [[ -e "$path" || -L "$path" ]]; then + printf 'present\t%s\t%s\n' "$path" "$key" >> "$list" + dst="$STATE_SNAPSHOT_DIR/data/$key" + cp -a "$path" "$dst" + else + printf 'absent\t%s\t%s\n' "$path" "$key" >> "$list" + fi + done + state_json_line snapshot P1 committed "pre-install snapshot=$STATE_SNAPSHOT_DIR" +} + +state_snapshot_restore() { + local status target key saved + [[ -s "$STATE_SNAPSHOT_DIR/paths.tsv" ]] || return 1 + while IFS=$'\t' read -r status target key; do + [[ -n "$target" ]] || continue + saved="$STATE_SNAPSHOT_DIR/data/$key" + if ! state_path_is_safe_target "$target"; then + fail "Rollback refused unsafe or replaced target path: $target" + return 1 + fi + rm -rf -- "$target" || return + if [[ "$status" == "present" ]]; then + mkdir -p "$(dirname "$target")" || return + cp -a "$saved" "$target" || return + fi + done < "$STATE_SNAPSHOT_DIR/paths.tsv" +} + +state_begin_install() { + local run_id + if ! install -d -m 0700 "$STATE_DIR"; then + fail "P1 Preflight failed: cannot create private journal directory $STATE_DIR" + exit 1 + fi + exec {STATE_LOCK_FD}>"$STATE_DIR/install.lock" + if ! flock -n "$STATE_LOCK_FD"; then + fail "P1 Preflight failed: another Mosaic install holds $STATE_DIR/install.lock" + echo " Remediation: wait for the active install to finish, then rerun." >&2 + exit 1 + fi + run_id="$(date -u +%Y%m%dT%H%M%SZ)-$$" + STATE_RUN_DIR="$STATE_DIR/$run_id" + if ! install -d -m 0700 "$STATE_RUN_DIR"; then + fail "P1 Preflight failed: cannot create private journal run directory $STATE_RUN_DIR" + exit 1 + fi + if [[ -f "$STATE_DIR/active.json" ]] \ + && grep -q '"status"[[:space:]]*:[[:space:]]*"in-progress"' "$STATE_DIR/active.json"; then + STATE_INTERRUPTED_ACTIVE="$STATE_RUN_DIR/prior-active.json" + if ! cp "$STATE_DIR/active.json" "$STATE_INTERRUPTED_ACTIVE"; then + fail "P1 Preflight failed: could not preserve the interrupted transaction projection." + exit 1 + fi + fi + STATE_JOURNAL="$STATE_RUN_DIR/journal.ndjson" + STATE_COMMAND_LOG="$STATE_RUN_DIR/commands.log" + STATE_FRAMEWORK_STATUS="$STATE_RUN_DIR/framework-phase-status.tsv" + if ! install -m 0600 /dev/null "$STATE_JOURNAL" \ + || ! install -m 0600 /dev/null "$STATE_COMMAND_LOG" \ + || ! install -m 0600 /dev/null "$STATE_FRAMEWORK_STATUS"; then + fail "P1 Preflight failed: cannot initialize private journal files in $STATE_RUN_DIR" + exit 1 + fi + export MOSAIC_INSTALL_COMMAND_LOG="$STATE_COMMAND_LOG" + export MOSAIC_INSTALL_PHASE_STATUS_FILE="$STATE_FRAMEWORK_STATUS" + export NPM_CONFIG_CACHE="$STATE_RUN_DIR/npm-cache" + state_write_active "$(printf '{\"status\":\"in-progress\",\"run\":\"%s\",\"journal\":\"%s\"}' "$run_id" "$STATE_JOURNAL")" + state_json_line install P0 opened "transaction opened before target mutation" + if [[ -n "$STATE_INTERRUPTED_ACTIVE" ]]; then + state_json_line recovery P1 resumed "stale in-progress projection preserved at $STATE_INTERRUPTED_ACTIVE; OS lock was free; current run starts from the honestly retained partial state" + fi +} + +state_handle_unexpected_failure() { + local code="$1" phase="${2:-$STATE_CURRENT_PHASE}" + trap - ERR INT TERM + set +e + state_json_line install "$phase" failed "unexpected command failure exit=$code; rollback started" + if state_snapshot_restore; then + state_json_line install "$phase" rolled-back "pre-install snapshot restored" + state_write_active "$(printf '{\"status\":\"rolled-back\",\"phase\":\"%s\",\"journal\":\"%s\"}' "$phase" "$STATE_JOURNAL")" + fail "$phase $(phase_name "$phase") failed (exit $code); pre-install snapshot restored." + else + state_json_line install "$phase" rollback-failed "snapshot restoration failed or refused an unsafe target" + state_write_active "$(printf '{\"status\":\"rollback-failed\",\"phase\":\"%s\",\"journal\":\"%s\"}' "$phase" "$STATE_JOURNAL")" + fail "$phase $(phase_name "$phase") failed (exit $code); automatic rollback did not complete." + fi + echo " Remediation: inspect $STATE_COMMAND_LOG and $STATE_JOURNAL, correct the named failure, then rerun." >&2 + exit "$code" +} + +state_mark_resumable_failure() { + local failed="${STATE_FAILED_PHASES[*]}" + trap - ERR INT TERM + state_json_line install P9 failed-resumable "failed phases=$failed; mutations retained for explicit remediation" + state_write_active "$(printf '{\"status\":\"failed-resumable\",\"phases\":\"%s\",\"journal\":\"%s\"}' "$failed" "$STATE_JOURNAL")" + fail "P9 Verify + commit failed: postconditions failed in ${failed:-unknown}." + echo " Remediation: fix each named phase, then run this installer with --check; journal: $STATE_JOURNAL" >&2 +} + +state_self_test() { + local phase path + state_begin_install + state_snapshot_create + trap 'state_handle_unexpected_failure "$?" "$STATE_CURRENT_PHASE"' ERR INT TERM + for phase in P2 P3 P4 P5 P6 P7 P8; do + state_phase_begin "$phase" + case "$phase" in + P2) path="$MOSAIC_HOME/.selftest-artifact" ;; + P3) path="$PREFIX/bin/mosaic" ;; + P4) path="$MOSAIC_HOME/.selftest-framework" ;; + P5) path="$MOSAIC_HOME/SOUL.md" ;; + P6) path="$HOME/.claude/settings.json" ;; + P7) path="$MOSAIC_HOME/.selftest-service" ;; + P8) path="$HOME/.bashrc" ;; + esac + state_record_mutation "$phase" "$path" "restore representative path from $STATE_SNAPSHOT_DIR" + mkdir -p "$(dirname "$path")" + printf 'mutated-by-%s\n' "$phase" > "$path" + state_phase_finish "$phase" committed "representative mutation committed" + if [[ "${MOSAIC_INSTALL_FAULT_AFTER:-}" == "$phase" ]]; then + state_json_line fault "$phase" injected "phase=$phase" + echo "Injected installer fault: phase=$phase" >&2 + state_snapshot_restore + state_json_line install "$phase" rolled-back "fault injection restored pre-install snapshot" + state_write_active "$(printf '{\"status\":\"rolled-back\",\"phase\":\"%s\",\"journal\":\"%s\"}' "$phase" "$STATE_JOURNAL")" + exit 97 + fi + done + fail "self-test requires MOSAIC_INSTALL_FAULT_AFTER=P2..P8" + exit 2 +} + +resolve_source_commit() { + local encoded_ref body headers content_type + encoded_ref="$(node -p 'encodeURIComponent(process.argv[1])' "$GIT_REF")" + body="$(mktemp "${TMPDIR:-/tmp}/mosaic-ref.XXXXXX.json")" || return + headers="$(mktemp "${TMPDIR:-/tmp}/mosaic-ref.XXXXXX.headers")" || { rm -f "$body"; return 1; } + if ! curl -fsSL -D "$headers" -o "$body" \ + "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/commits?sha=${encoded_ref}&limit=1"; then + rm -f "$body" "$headers" + fail "P2 Acquire artifacts failed: could not resolve source ref '$GIT_REF'." + return 1 + fi + content_type="$(awk 'BEGIN{IGNORECASE=1} /^content-type:/{gsub(/\r/,""); sub(/^[^:]+:[[:space:]]*/,""); print; exit}' "$headers")" + if [[ "$content_type" != application/json* ]]; then + rm -f "$body" "$headers" + fail "P2 Acquire artifacts failed: ref endpoint returned content-type '${content_type:-missing}', not JSON." + return 1 + fi + RESOLVED_SOURCE_COMMIT="$(node -e ' + const fs=require("fs"); const rows=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); + if (!Array.isArray(rows) || rows.length!==1 || typeof rows[0].sha!=="string" || !/^[0-9a-f]{40}$/.test(rows[0].sha)) process.exit(1); + process.stdout.write(rows[0].sha); + ' "$body")" || { + rm -f "$body" "$headers" + fail "P2 Acquire artifacts failed: ref endpoint did not return exactly one commit with a sha." + return 1 + } + rm -f "$body" "$headers" + ARCHIVE_URL="${REPO_BASE}/archive/${RESOLVED_SOURCE_COMMIT}.tar.gz" +} + +# Download + extract the monorepo archive at the resolved immutable commit +# exactly once per run. Sets EXTRACTED_DIR for both P3 source fallback and P4. ensure_monorepo() { if [[ -n "$EXTRACTED_DIR" ]] && [[ -d "$EXTRACTED_DIR" ]]; then return 0 fi - require_cmd tar + require_cmd tar || return - WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-XXXXXX")" - # shellcheck disable=SC2317 + if [[ -n "$STATE_RUN_DIR" ]]; then + WORK_DIR="$STATE_RUN_DIR/work" + mkdir -p "$WORK_DIR" || return + else + WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-XXXXXX")" || return + fi + # shellcheck disable=SC2329 # Invoked by the EXIT trap below. cleanup_work() { [[ -n "$WORK_DIR" ]] && rm -rf "$WORK_DIR"; } trap cleanup_work EXIT - info "Downloading source from ${GIT_REF}…" - if command -v curl &>/dev/null; then - curl -fsSL "$ARCHIVE_URL" | tar xz -C "$WORK_DIR" - elif command -v wget &>/dev/null; then - wget -qO- "$ARCHIVE_URL" | tar xz -C "$WORK_DIR" + local archive="$WORK_DIR/source.tar.gz" + local max_archive_bytes="${MOSAIC_INSTALL_MAX_ARCHIVE_BYTES:-268435456}" + local max_expanded_bytes="${MOSAIC_INSTALL_MAX_EXPANDED_BYTES:-1073741824}" + if [[ -n "$LOCAL_SOURCE_ARCHIVE" ]]; then + if [[ ! "$LOCAL_SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ || ! "$LOCAL_SOURCE_SHA256" =~ ^[0-9a-f]{64}$ \ + || ! -f "$LOCAL_SOURCE_ARCHIVE" || -L "$LOCAL_SOURCE_ARCHIVE" ]]; then + fail "P2 Acquire artifacts failed: local checkout fixture requires a regular archive plus exact 40-hex source ID and 64-hex SHA-256." + return 1 + fi + RESOLVED_SOURCE_COMMIT="$LOCAL_SOURCE_COMMIT" + cp "$LOCAL_SOURCE_ARCHIVE" "$archive" || return + RESOLVED_SOURCE_DIGEST="$(sha256sum "$archive" | awk '{print $1}')" || return + if [[ "$RESOLVED_SOURCE_DIGEST" != "$LOCAL_SOURCE_SHA256" ]]; then + fail "P2 Acquire artifacts failed: local checkout archive digest does not match the fixture-pinned SHA-256." + return 1 + fi + info "Acquiring checkout fixture at content ID ${RESOLVED_SOURCE_COMMIT} with pinned SHA-256 ${RESOLVED_SOURCE_DIGEST}…" else - fail "curl or wget required to download source." - exit 1 + [[ -n "${RESOLVED_SOURCE_COMMIT:-}" ]] || resolve_source_commit || return + info "Downloading source ref ${GIT_REF} at pinned commit ${RESOLVED_SOURCE_COMMIT}…" + if command -v curl &>/dev/null; then + curl -fsSL --max-filesize "$max_archive_bytes" "$ARCHIVE_URL" -o "$archive" || return + elif command -v wget &>/dev/null; then + wget -O "$archive" "$ARCHIVE_URL" || return + else + fail "curl or wget required to download source." + return 1 + fi + RESOLVED_SOURCE_DIGEST="$(sha256sum "$archive" | awk '{print $1}')" || return fi + local archive_bytes + archive_bytes="$(stat -c '%s' "$archive" 2>/dev/null)" || return + if [[ ! "$archive_bytes" =~ ^[0-9]+$ || "$archive_bytes" -gt "$max_archive_bytes" ]]; then + fail "P2 Acquire artifacts failed: source archive exceeds the configured compressed-size limit." + return 1 + fi + # Reject traversal, links, devices, excessive entry counts, and expansion + # bombs before tar writes a byte. The immutable commit + digest are retained + # as provenance; authenticated release metadata remains the trust root for a + # future distribution-artifact lane. + if ! MAX_EXPANDED_BYTES="$max_expanded_bytes" python3 - "$archive" <<'PY' +import os +import pathlib +import sys +import tarfile + +archive = sys.argv[1] +limit = int(os.environ["MAX_EXPANDED_BYTES"]) +total = 0 +with tarfile.open(archive, "r:gz") as tf: + members = tf.getmembers() + if not members or len(members) > 100_000: + raise SystemExit(1) + for member in members: + pure = pathlib.PurePosixPath(member.name) + if pure.is_absolute() or ".." in pure.parts or member.issym() or member.islnk() or member.isdev(): + raise SystemExit(1) + if not (member.isfile() or member.isdir()): + raise SystemExit(1) + total += member.size + if total > limit: + raise SystemExit(1) +PY + then + fail "P2 Acquire artifacts failed: archive safety/integrity check failed (sha256=$RESOLVED_SOURCE_DIGEST)." + return 1 + fi + tar xzf "$archive" -C "$WORK_DIR" || return + state_json_line artifact P2 committed "lane=$GIT_REF source_commit=$RESOLVED_SOURCE_COMMIT sha256=$RESOLVED_SOURCE_DIGEST" || return # Gitea archives extract to / inside the work dir - EXTRACTED_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)" + EXTRACTED_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)" || return if [[ -z "$EXTRACTED_DIR" ]] || [[ ! -d "$EXTRACTED_DIR" ]]; then fail "Could not locate extracted source in archive." - ls -la "$WORK_DIR" >&2 - exit 1 + ls -la "$WORK_DIR" >&2 || true # Diagnostic only; the named P2 failure is authoritative. + return 1 fi } @@ -421,7 +1148,7 @@ ensure_monorepo() { install_cli_from_source() { local src="$EXTRACTED_DIR" local out_dir="$WORK_DIR/dist-tarballs" - mkdir -p "$out_dir" + mkdir -p "$out_dir" || return # pnpm via corepack (ships with Node >= 16.9; required by Node >= 20 preflight). # Pin to the repo's packageManager version so the build matches CI. Surface @@ -436,18 +1163,18 @@ install_cli_from_source() { if ! command -v pnpm &>/dev/null; then fail "pnpm not available after corepack activation." echo " Install pnpm manually (https://pnpm.io/installation) and re-run with --dev." - exit 1 + return 1 fi info "Installing workspace dependencies (pnpm install)…" - ( cd "$src" && pnpm install ) 2>&1 | sed 's/^/ /' + ( cd "$src" && pnpm install ) 2>&1 | sed 's/^/ /' || return info "Building CLI + gateway from source…" - ( cd "$src" && pnpm --filter "@mosaicstack/mosaic..." --filter "@mosaicstack/gateway..." run build ) 2>&1 | sed 's/^/ /' + ( cd "$src" && pnpm --filter "@mosaicstack/mosaic..." --filter "@mosaicstack/gateway..." run build ) 2>&1 | sed 's/^/ /' || return info "Packing local tarballs…" - ( cd "$src/packages/mosaic" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /' - ( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /' + ( cd "$src/packages/mosaic" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /' || return + ( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /' || return local cli_tgz gw_tgz cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')" @@ -455,22 +1182,28 @@ install_cli_from_source() { if [[ ! -f "$cli_tgz" ]]; then fail "CLI tarball was not produced by pnpm pack." - exit 1 + return 1 fi if [[ ! -f "$gw_tgz" ]]; then fail "Gateway tarball was not produced by pnpm pack." - exit 1 + return 1 fi # Gateway first so it is present globally before the CLI's wizard runs (which # skips its own gateway install via MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1). info "Installing gateway from source tarball (global)…" - npm install -g "$gw_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' + npm install -g "$gw_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' || return info "Installing CLI from source tarball (global)…" - npm install -g "$cli_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' + npm install -g "$cli_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' || return - ok "Installed from source: CLI $(installed_cli_version)" + # Source fallback replaces the registry candidate with the package version + # produced by the pinned source commit. P3 must compare against what P2 + # actually selected, not the failed registry candidate. + RESOLVED_CLI_VERSION="$(installed_cli_version)" || return + [[ -n "$RESOLVED_CLI_VERSION" ]] || { fail "Source install did not expose an installed CLI version."; return 1; } + state_json_line artifact P2 committed "source fallback selected cli_version=$RESOLVED_CLI_VERSION source_commit=${RESOLVED_SOURCE_COMMIT:-unknown}" || return + ok "Installed from source: CLI $RESOLVED_CLI_VERSION" } install_next_cli_from_registry() { @@ -516,10 +1249,27 @@ install_next_cli_from_registry() { ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}" } -# ─── preflight ──────────────────────────────────────────────────────────────── +# ─── preflight / state-machine dispatch ────────────────────────────────────── + +if [[ "$FLAG_STATE_SELF_TEST" == "true" ]]; then + require_cmd node + require_cmd flock + state_self_test +fi + +# `--check` exits before mkdir, npm-prefix setup, locks, snapshots, downloads, or +# any other target mutation. Temporary observation files live under TMPDIR and +# are removed in the predicate that creates them. +if [[ "$FLAG_CHECK" == "true" ]]; then + check_status=0 + state_check_all || check_status=$? + rm -rf "$STATE_NPM_CACHE" + exit "$check_status" +fi require_cmd node require_cmd npm +require_cmd flock NODE_MAJOR="$(node -e 'process.stdout.write(String(process.versions.node.split(".")[0]))')" if [[ "$NODE_MAJOR" -lt 20 ]]; then @@ -531,10 +1281,53 @@ echo "" echo "${BOLD}Mosaic Stack Installer${RESET}" echo "" +# P0/P1 are pure preconditions. Open the durable journal and snapshot only after +# they pass, but before P2 performs the first target mutation. +if state_predicate P0; then + P0_REASON="$STATE_REASON" + state_emit P0 PASS "$P0_REASON" +else + state_emit P0 FAIL "$STATE_REASON" + fail "P0 Resolve context failed." + echo " Remediation: run as a supported non-root target user with explicit HOME/shell, glibc x86_64, and Node.js >=20." >&2 + exit 1 +fi +if state_predicate P1; then + P1_REASON="$STATE_REASON" + state_emit P1 PASS "$P1_REASON" +else + state_emit P1 FAIL "$STATE_REASON" + fail "P1 Preflight failed." + echo " Remediation: install the named prerequisites, clear any active transaction, and ensure the npm prefix parent is writable." >&2 + exit 1 +fi +state_begin_install +state_phase_finish P0 committed "$P0_REASON" +state_phase_finish P1 committed "$P1_REASON; exclusive lock acquired; journal opened" +state_snapshot_create +trap 'state_handle_unexpected_failure "$?" "$STATE_CURRENT_PHASE"' ERR INT TERM + +state_phase_begin P2 +state_record_mutation P2 "$STATE_RUN_DIR/work" "discard acquired temporary artifacts" +if [[ "$FLAG_DEV" == "true" ]]; then + RESOLVED_CLI_VERSION="" +else + RESOLVED_CLI_VERSION="$(state_resolved_version)" + if [[ -z "$RESOLVED_CLI_VERSION" ]]; then + fail "P2 Acquire artifacts failed: could not resolve a pinned CLI version for the requested lane." + false + fi +fi +if [[ "$FLAG_FRAMEWORK" == "true" || "$FLAG_DEV" == "true" ]]; then + state_run_captured "P2 acquire pinned source archive" ensure_monorepo +fi +state_phase_finish P2 committed "lane=$([[ "$FLAG_NEXT" == true ]] && echo next || echo latest) cli_version=${RESOLVED_CLI_VERSION:-pending-source-package-build} source_commit=${RESOLVED_SOURCE_COMMIT:-deferred-until-source-fallback} sha256=${RESOLVED_SOURCE_DIGEST:-deferred-until-source-fallback}" + # ═══════════════════════════════════════════════════════════════════════════════ # PART 1: Framework (bash launcher + guides + runtime configs + tools) # ═══════════════════════════════════════════════════════════════════════════════ +install_phase_p4_action() { if [[ "$FLAG_FRAMEWORK" == "true" ]]; then step "Framework (~/.config/mosaic)" @@ -559,15 +1352,15 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then warn "Framework not installed." fi else - # Download repo archive and extract framework (shared with the dev build) - ensure_monorepo + # Download repo archive and extract framework (shared with the dev build). + ensure_monorepo || return FRAMEWORK_SRC="$EXTRACTED_DIR/packages/mosaic/framework" if [[ ! -d "$FRAMEWORK_SRC" ]]; then fail "Framework not found in archive at packages/mosaic/framework/" fail "Archive contents:" - ls -la "$WORK_DIR" >&2 - exit 1 + ls -la "$WORK_DIR" >&2 || true # Diagnostic only; missing framework remains fatal. + return 1 fi # Run the framework's own install.sh (handles keep/overwrite for SOUL.md etc.) @@ -575,7 +1368,7 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then MOSAIC_INSTALL_MODE="${MOSAIC_INSTALL_MODE:-keep}" \ MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1 \ MOSAIC_SKIP_SKILLS_SYNC="${MOSAIC_SKIP_SKILLS_SYNC:-0}" \ - bash "$FRAMEWORK_SRC/install.sh" + bash "$FRAMEWORK_SRC/install.sh" || return ok "Framework installed" echo "" @@ -584,18 +1377,20 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then # to mosaic-launch directly via its absolute path. fi fi +} # ═══════════════════════════════════════════════════════════════════════════════ # PART 2: @mosaicstack/mosaic (npm — TUI, gateway client, wizard, CLI) # ═══════════════════════════════════════════════════════════════════════════════ +install_phase_p3_action() { if [[ "$FLAG_CLI" == "true" ]]; then step "@mosaicstack/mosaic (npm package)" # Ensure prefix dir if [[ ! -d "$PREFIX" ]]; then info "Creating global prefix directory: $PREFIX" - mkdir -p "$PREFIX"/{bin,lib} + mkdir -p "$PREFIX"/{bin,lib} || return fi # Ensure npmrc scope mapping @@ -604,13 +1399,13 @@ if [[ "$FLAG_CLI" == "true" ]]; then if ! grep -qF "$SCOPE_LINE" "$NPMRC" 2>/dev/null; then info "Adding ${SCOPE} registry to $NPMRC" - echo "$SCOPE_LINE" >> "$NPMRC" + echo "$SCOPE_LINE" >> "$NPMRC" || return ok "Registry configured" fi if ! grep -qF "prefix=$PREFIX" "$NPMRC" 2>/dev/null; then if ! grep -q '^prefix=' "$NPMRC" 2>/dev/null; then - echo "prefix=$PREFIX" >> "$NPMRC" + echo "prefix=$PREFIX" >> "$NPMRC" || return info "Set npm global prefix to $PREFIX" fi fi @@ -675,8 +1470,8 @@ if [[ "$FLAG_CLI" == "true" ]]; then fi elif [[ "$FLAG_DEV" == "true" ]]; then info "Dev mode — building CLI + gateway from source at ref ${GIT_REF}…" - ensure_monorepo - install_cli_from_source + ensure_monorepo || return + install_cli_from_source || return # PATH check for npm prefix if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then @@ -690,8 +1485,8 @@ if [[ "$FLAG_CLI" == "true" ]]; 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 + ensure_monorepo || return + install_cli_from_source || return export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 fi @@ -705,13 +1500,13 @@ if [[ "$FLAG_CLI" == "true" ]]; then warn "Could not reach registry at $REGISTRY — skipping npm CLI." elif [[ -z "$CURRENT" ]]; then info "Installing ${CLI_PKG}@${LATEST}…" - npm install -g "${CLI_PKG}@${LATEST}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' + npm install -g "${CLI_PKG}@${LATEST}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' || return ok "CLI installed: $(installed_cli_version)" elif [[ "$CURRENT" == "$LATEST" ]]; then ok "Already at latest version ($LATEST)." elif version_lt "$CURRENT" "$LATEST"; then info "Upgrading ${CLI_PKG}: $CURRENT → $LATEST…" - npm install -g "${CLI_PKG}@${LATEST}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' + npm install -g "${CLI_PKG}@${LATEST}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' || return ok "CLI upgraded: $(installed_cli_version)" else ok "CLI is at or ahead of registry ($CURRENT ≥ $LATEST)." @@ -724,6 +1519,51 @@ if [[ "$FLAG_CLI" == "true" ]]; then fi fi fi +} + +# Execute actions in canonical order. The old installer ran P4 before P3, which +# made runtime-link diagnostics depend on shell discovery instead of P3's known +# absolute binary. P3 now commits before P4 begins. +state_phase_begin P3 +if [[ "$FLAG_CLI" == "true" ]]; then + state_record_mutation P3 "$PREFIX" "restore prefix from $STATE_SNAPSHOT_DIR" + state_record_mutation P3 "$HOME/.npmrc" "restore npmrc from $STATE_SNAPSHOT_DIR" +fi +state_run_captured "P3 install CLI" install_phase_p3_action +if [[ "$FLAG_CLI" == "false" ]]; then + state_phase_finish P3 not-requested "CLI component excluded by --framework" +elif state_predicate P3; then + state_phase_finish P3 committed "$STATE_REASON" +else + state_phase_finish P3 failed "$STATE_REASON" + fail "P3 Install CLI failed: $STATE_REASON" + false +fi + +state_phase_begin P4 +if [[ "$FLAG_FRAMEWORK" == "true" ]]; then + state_record_mutation P4 "$MOSAIC_HOME" "restore framework tree from $STATE_SNAPSHOT_DIR" + state_record_mutation P4 "$HOME/.pi" "restore Pi runtime assets from $STATE_SNAPSHOT_DIR" + state_record_mutation P4 "$HOME/.claude" "restore Claude runtime assets from $STATE_SNAPSHOT_DIR" + state_record_mutation P4 "$HOME/.codex" "restore Codex runtime assets from $STATE_SNAPSHOT_DIR" + state_record_mutation P4 "$HOME/.config/opencode" "restore OpenCode runtime assets from $STATE_SNAPSHOT_DIR" + state_record_mutation P4 "$HOME/.local/state/mosaic/backups" "restore framework backup state from $STATE_SNAPSHOT_DIR" + state_record_mutation P6 "$HOME/.claude/settings.json" "restore activation settings from $STATE_SNAPSHOT_DIR" +fi +state_run_captured "P4 install framework and skills; P6 evaluate activation" install_phase_p4_action +if [[ "$FLAG_FRAMEWORK" == "false" ]]; then + state_phase_finish P4 not-requested "framework component excluded by --cli" +elif state_predicate P4; then + state_phase_finish P4 committed "$STATE_REASON" +else + # C1 intentionally cannot commit P4 while the shipped-set declaration is + # absent. Keep the partial state for P5-P8 diagnostics; P9 fails non-zero. + state_phase_finish P4 failed-resumable "$STATE_REASON" +fi + +# P5/P7 actions (wizard/service requests) live in the summary flow below and +# bind their mutation records immediately before the wizard executes. P8 is +# observation-only today, so it must not fabricate planned mutation entries. # ═══════════════════════════════════════════════════════════════════════════════ # Summary @@ -759,7 +1599,13 @@ if [[ "$FLAG_CHECK" == "false" ]]; then MOSAIC_CMD="$MOSAIC_BIN" fi - if "$MOSAIC_CMD" wizard; then + state_record_mutation P5 "$MOSAIC_HOME/SOUL.md" "restore identity from $STATE_SNAPSHOT_DIR" + state_record_mutation P5 "$MOSAIC_HOME/USER.md" "restore identity from $STATE_SNAPSHOT_DIR" + state_record_mutation P7 "$HOME/.config/mosaic-gateway" "stop requested services and restore service state" + state_record_mutation P7 "$HOME/.config/systemd" "stop requested services and restore user units" + state_record_mutation P7 "$HOME/.local/share/systemd" "stop requested services and restore user units" + state_record_mutation P7 "$HOME/.local/state/mosaic-gateway" "stop requested services and restore service state" + if state_run_captured "P5 identity and P7 service wizard" "$MOSAIC_CMD" wizard; then ok "Wizard complete." else warn "Wizard exited non-zero." @@ -776,8 +1622,8 @@ if [[ "$FLAG_CHECK" == "false" ]]; then fi # ── Write install manifest ────────────────────────────────────────────────── - # Records what was mutated so that `mosaic uninstall` can precisely reverse it. - # Written last (after all mutations) so an incomplete install leaves no manifest. + # The mutation journal was opened before P2. This projection is written as + # pending-verification and becomes committed only after P9 reasserts P0-P8. MANIFEST_PATH="$MOSAIC_HOME/.install-manifest.json" MANIFEST_CLI_VERSION="$(installed_cli_version)" MANIFEST_FW_VERSION="$(framework_version)" @@ -821,6 +1667,10 @@ if [[ "$FLAG_CHECK" == "false" ]]; then } RUNTIME_COPIES="$(collect_runtime_copies)" + MANIFEST_P4_OUTCOME="committed" + MANIFEST_P6_OUTCOME="committed" + state_framework_action_failed P4 && MANIFEST_P4_OUTCOME="failed" + state_framework_action_failed P6 && MANIFEST_P6_OUTCOME="failed" # Check whether the npmrc line was present (we may have added it above) NPMRC_LINES_JSON="[]" @@ -828,15 +1678,24 @@ if [[ "$FLAG_CHECK" == "false" ]]; then NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]" fi + MANIFEST_TMP="$MOSAIC_HOME/.install-manifest.json.tmp-$$" + state_record_mutation P9 "$MANIFEST_PATH" "restore manifest/framework tree from $STATE_SNAPSHOT_DIR" + state_record_mutation P9 "$MANIFEST_TMP" "remove pending manifest temp or restore framework tree from $STATE_SNAPSHOT_DIR" if node -e " const fs = require('fs'); const path = require('path'); const p = process.argv[1]; const m = { - version: 1, + version: 2, + status: 'pending-verification', installedAt: process.argv[2], cliVersion: process.argv[3] || '(unknown)', frameworkVersion: parseInt(process.argv[4] || '0', 10), + lane: process.argv[7], + sourceCommit: process.argv[8], + sourceSha256: process.argv[9], + journal: process.argv[10], + phaseOutcomes: { P4: process.argv[11], P6: process.argv[12] }, mutations: { directories: [path.dirname(p)], npmGlobalPackages: ['@mosaicstack/mosaic'], @@ -846,21 +1705,68 @@ if [[ "$FLAG_CHECK" == "false" ]]; then } }; fs.mkdirSync(path.dirname(p), { recursive: true }); - fs.writeFileSync(p, JSON.stringify(m, null, 2) + '\\n', { mode: 0o600 }); + const tmp=process.argv[13]; + const fd=fs.openSync(tmp,'wx',0o600); + try { fs.writeFileSync(fd,JSON.stringify(m,null,2)+'\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } + fs.renameSync(tmp,p); + const dfd=fs.openSync(path.dirname(p),'r'); + try { fs.fsyncSync(dfd); } finally { fs.closeSync(dfd); } " \ "$MANIFEST_PATH" \ "$MANIFEST_TS" \ "$MANIFEST_CLI_VERSION" \ "$MANIFEST_FW_VERSION" \ "$NPMRC_LINES_JSON" \ - "$RUNTIME_COPIES" 2>/dev/null; then - ok "Install manifest written: $MANIFEST_PATH" + "$RUNTIME_COPIES" \ + "$([[ "$FLAG_NEXT" == true ]] && echo next || echo latest)" \ + "${RESOLVED_SOURCE_COMMIT:-not-requested}" \ + "${RESOLVED_SOURCE_DIGEST:-not-requested}" \ + "$STATE_JOURNAL" \ + "$MANIFEST_P4_OUTCOME" \ + "$MANIFEST_P6_OUTCOME" \ + "$MANIFEST_TMP"; then + ok "Install manifest written pending P9 verification: $MANIFEST_PATH" else - warn "Could not write install manifest (non-fatal)" + fail "P9 Verify + commit could not durably write the install manifest." + false fi + # Record each deferred phase independently before the aggregate P9 verdict. + for phase in P5 P6 P7 P8; do + state_phase_begin "$phase" + if [[ "$FLAG_CLI" == "true" && "$FLAG_FRAMEWORK" == "false" ]]; then + state_phase_finish "$phase" not-requested "not requested by --cli component-only install" + elif state_predicate "$phase"; then + state_phase_finish "$phase" committed "$STATE_REASON" + else + state_phase_finish "$phase" failed-resumable "$STATE_REASON" + fi + done + echo "" - ok "Done." + state_phase_begin P9 + if state_check_install_scope && [[ -s "$MANIFEST_PATH" ]]; then + MANIFEST_COMMIT_TMP="$MOSAIC_HOME/.install-manifest.json.commit-tmp-$$" + state_record_mutation P9 "$MANIFEST_COMMIT_TMP" "remove committed manifest temp or restore framework tree from $STATE_SNAPSHOT_DIR" + node -e ' + const fs=require("fs"), path=require("path"); const p=process.argv[1]; const m=JSON.parse(fs.readFileSync(p,"utf8")); + m.status="committed"; m.committedAt=new Date().toISOString(); + const tmp=process.argv[2]; const fd=fs.openSync(tmp,"wx",0o600); + try { fs.writeFileSync(fd,JSON.stringify(m,null,2)+"\n"); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } + fs.renameSync(tmp,p); const dfd=fs.openSync(path.dirname(p),"r"); + try { fs.fsyncSync(dfd); } finally { fs.closeSync(dfd); } + ' "$MANIFEST_PATH" "$MANIFEST_COMMIT_TMP" + state_json_line install P9 committed "all postconditions verified" + state_phase_finish P9 committed "P0-P8 reasserted; manifest durably committed; journal ready to seal" + state_write_active "$(printf '{\"status\":\"committed\",\"journal\":\"%s\"}' "$STATE_JOURNAL")" + state_seal_journal >/dev/null + trap - ERR INT TERM + ok "Done." + else + state_phase_finish P9 failed-resumable "failed phases=${STATE_FAILED_PHASES[*]}" + state_mark_resumable_failure + exit 1 + fi fi } # end main -- 2.54.0