diff --git a/apps/gateway/src/app.module.ts b/apps/gateway/src/app.module.ts index 380b5e63..5d160079 100644 --- a/apps/gateway/src/app.module.ts +++ b/apps/gateway/src/app.module.ts @@ -25,6 +25,7 @@ import { HarnessModule } from './harness/harness.module.js'; import { ReloadModule } from './reload/reload.module.js'; import { WorkspaceModule } from './workspace/workspace.module.js'; import { HierarchyModule } from './hierarchy/hierarchy.module.js'; +import { EnrollmentModule } from './enrollment/enrollment.module.js'; import { QueueModule } from './queue/queue.module.js'; import { FederationModule } from './federation/federation.module.js'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; @@ -67,6 +68,7 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder ReloadModule, WorkspaceModule, HierarchyModule, + EnrollmentModule, ...(federationEnabled ? [FederationModule] : []), ], controllers: [HealthController], diff --git a/apps/gateway/src/enrollment/enrollment-commands.integration.test.ts b/apps/gateway/src/enrollment/enrollment-commands.integration.test.ts new file mode 100644 index 00000000..b7408105 --- /dev/null +++ b/apps/gateway/src/enrollment/enrollment-commands.integration.test.ts @@ -0,0 +1,508 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { Logger, ValidationPipe, type ExecutionContext } from '@nestjs/common'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import supertest from 'supertest'; +import { unseal } from '@mosaicstack/auth'; +import { + agentAuditEvents, + agentIdempotencyFence, + agentOutbox, + agents, + and, + createPgliteDb, + eq, + providerCredentials, + runPgliteMigrations, + sql, + users, + type DbHandle, +} from '@mosaicstack/db'; +import { DB } from '../database/database.module.js'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { HarnessRegistry } from '../harness/harness.registry.js'; +import { HARNESS_REGISTRY } from '../harness/harness.tokens.js'; +import { FakeHarnessAdapter } from '../harness/testing/fake-harness.adapter.js'; +import { EnrollmentController } from './enrollment.controller.js'; +import { + EnrollmentRepository, + type EnrollAgentInput, + type EnrollmentResult, + type EnrolledAgentView, +} from './enrollment.repository.js'; +import { EnrollmentService } from './enrollment.service.js'; + +/** + * Command-level witnesses for the agent enrollment family (M4-4b) — design + * docs/plans/2026-08-29-agent-enrollment-command-design.md §5 items 1–9 and + * 11 (item 10, CLI parity, lives in packages/mosaic). Schema-level + * constraints are witnessed in packages/db/src/agent-enrollment.witness.test.ts. + * + * The suite runs the REAL repository/service/controller graph over PGlite, + * with only AuthGuard overridden (a session store is out of scope; the + * override binds request.user exactly as the real guard does). The §6.3 + * static companions — no `any`-typed boundary pass-through, a single audit + * emitter (EnrollmentRepository.appendEvent) — are code-surface properties + * reviewed on the PR, not runtime probes. + */ +describe('enrollment commands integration', (): void => { + let dataDir: string; + let handle: DbHandle; + let moduleRef: TestingModule; + let app: NestFastifyApplication; + let http: ReturnType; + let repo: EnrollmentRepository; + let previousAuthSecret: string | undefined; + + const OWNER = 'enr-owner'; + const ADMIN = 'enr-admin'; + const STRANGER = 'enr-stranger'; + const HARNESS = 'fake-harness'; + /** Never-echo probe value (§5.1). Unique enough that any leak is unambiguous. */ + const SECRET = `enr-secret-value-${randomUUID()}`; + + /** The HTTP-leg acting user; the overridden guard binds it per request. */ + let currentUserId = OWNER; + + const enrollInput = (overrides: Partial = {}): EnrollAgentInput => ({ + actorId: OWNER, + harness: HARNESS, + name: `Agent ${randomUUID().slice(0, 8)}`, + persona: null, + model: 'anthropic/claude-test', + provider: `prov-${randomUUID().slice(0, 8)}`, + credential: { mode: 'intake', type: 'api_key', value: SECRET }, + idempotencyKey: randomUUID(), + ...overrides, + }); + + function expectOk(result: EnrollmentResult): { ok: true; correlationId: string } & T { + if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`); + return result; + } + + function expectFail( + result: EnrollmentResult, + error: string, + ): { ok: false; error: string; message: string; correlationId: string } { + if (result.ok) throw new Error(`expected ${error}, got ok`); + expect(result.error).toBe(error); + return result; + } + + const fenceForKey = (key: string) => + handle.db + .select() + .from(agentIdempotencyFence) + .where(eq(agentIdempotencyFence.idempotencyKey, key)); + + const eventsForAgent = (agentId: string) => + handle.db.select().from(agentAuditEvents).where(eq(agentAuditEvents.agentId, agentId)); + + const agentsNamed = (name: string) => + handle.db.select().from(agents).where(eq(agents.name, name)); + + const credentialsFor = (userId: string, provider: string) => + handle.db + .select() + .from(providerCredentials) + .where( + and(eq(providerCredentials.userId, userId), eq(providerCredentials.provider, provider)), + ); + + const allOutbox = () => handle.db.select().from(agentOutbox); + + beforeAll(async (): Promise => { + previousAuthSecret = process.env['BETTER_AUTH_SECRET']; + process.env['BETTER_AUTH_SECRET'] = 'enrollment-witness-sealing-key'; + + dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-enrollment-commands-')); + handle = createPgliteDb(dataDir); + await runPgliteMigrations(handle); + + const registry = new HarnessRegistry(); + registry.register(new FakeHarnessAdapter({ id: HARNESS })); + + moduleRef = await Test.createTestingModule({ + controllers: [EnrollmentController], + providers: [ + EnrollmentRepository, + EnrollmentService, + { provide: DB, useValue: handle.db }, + { provide: HARNESS_REGISTRY, useValue: registry }, + ], + }) + .overrideGuard(AuthGuard) + .useValue({ + canActivate: (ctx: ExecutionContext): boolean => { + const request = ctx.switchToHttp().getRequest<{ user?: unknown }>(); + request.user = { id: currentUserId }; + return true; + }, + }) + .compile(); + + app = moduleRef.createNestApplication(new FastifyAdapter()); + // Mirror main.ts exactly — the closure witnesses depend on these options. + app.useGlobalPipes( + new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }), + ); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + http = supertest(app.getHttpServer()); + repo = moduleRef.get(EnrollmentRepository); + + await handle.db.insert(users).values([ + { id: OWNER, name: 'Owner', email: `${OWNER}@example.com` }, + { id: ADMIN, name: 'Admin', email: `${ADMIN}@example.com`, role: 'admin' }, + { id: STRANGER, name: 'Stranger', email: `${STRANGER}@example.com` }, + ]); + }); + + afterAll(async (): Promise => { + await app?.close(); + await handle.close(); + await rm(dataDir, { recursive: true, force: true }); + if (previousAuthSecret === undefined) delete process.env['BETTER_AUTH_SECRET']; + else process.env['BETTER_AUTH_SECRET'] = previousAuthSecret; + }); + + // ── §5.7 wizard-facing zero-mutation (runs FIRST: no call → zero rows) ──── + + it('zero-mutation: with no enrollment invocation the family tables hold zero rows', async () => { + expect(await handle.db.select().from(agents)).toHaveLength(0); + expect(await handle.db.select().from(agentAuditEvents)).toHaveLength(0); + expect(await handle.db.select().from(agentOutbox)).toHaveLength(0); + expect(await handle.db.select().from(agentIdempotencyFence)).toHaveLength(0); + }); + + // ── §5.1 never-echo + §5.2 sealed single-copy ───────────────────────────── + + it('never echoes the intake credential value: HTTP result, audit, outbox, fence, and logs are clean', async () => { + const logSink: string[] = []; + const logSpies = (['log', 'error', 'warn', 'debug', 'verbose'] as const).map((method) => + vi.spyOn(Logger.prototype, method).mockImplementation((...args: unknown[]) => { + logSink.push(args.map(String).join(' ')); + }), + ); + try { + currentUserId = OWNER; + const provider = `prov-echo-${randomUUID().slice(0, 8)}`; + const res = await http.post('/api/enrollment/agents').send({ + harness: HARNESS, + name: 'Echo Probe', + persona: 'a persona', + model: 'anthropic/claude-test', + provider, + credential: { mode: 'intake', type: 'api_key', value: SECRET }, + idempotencyKey: randomUUID(), + }); + expect(res.status).toBe(201); + expect(res.text).not.toContain(SECRET); + const agentId = (res.body as { agent: EnrolledAgentView }).agent.id; + + const events = await eventsForAgent(agentId); + expect(events).toHaveLength(1); + expect(JSON.stringify(events)).not.toContain(SECRET); + expect(JSON.stringify(await allOutbox())).not.toContain(SECRET); + const fences = await handle.db + .select() + .from(agentIdempotencyFence) + .where(eq(agentIdempotencyFence.outcomeAgentId, agentId)); + expect(fences).toHaveLength(1); + expect(JSON.stringify(fences)).not.toContain(SECRET); + expect(logSink.join('\n')).not.toContain(SECRET); + + // §5.2 sealed single-copy: exactly one provider_credentials row, sealed + // at rest, and it round-trips through unseal — no plaintext column. + const creds = await credentialsFor(OWNER, provider); + expect(creds).toHaveLength(1); + expect(creds[0]?.encryptedValue).not.toBe(SECRET); + expect(creds[0]?.encryptedValue).not.toContain(SECRET); + expect(unseal(creds[0]?.encryptedValue as string)).toBe(SECRET); + } finally { + logSpies.forEach((spy) => spy.mockRestore()); + } + }); + + it('the agents table itself has no credential-bearing column (§5.2)', async () => { + const result = (await handle.db.execute( + sql`select column_name from information_schema.columns where table_name = 'agents'`, + )) as unknown as { rows?: Array<{ column_name: string }> } & Array<{ column_name: string }>; + const names = (result.rows ?? result).map((row) => row.column_name); + expect(names.length).toBeGreaterThan(0); + for (const name of names) { + expect(name).not.toMatch(/credential|secret|token|api_key/i); + } + }); + + // ── §5.3 reference resolution ───────────────────────────────────────────── + + it('refuses an unresolvable credential reference with precondition_failed and creates nothing', async () => { + const input = enrollInput({ credential: { mode: 'reference' } }); + const result = await repo.enroll(input); + expectFail(result, 'precondition_failed'); + expect(await agentsNamed(input.name)).toHaveLength(0); + expect(await fenceForKey(input.idempotencyKey)).toHaveLength(0); + }); + + it('resolves a reference credential stored earlier for (actor, provider)', async () => { + const provider = `prov-ref-${randomUUID().slice(0, 8)}`; + const seeded = expectOk(await repo.enroll(enrollInput({ provider }))); + const result = expectOk( + await repo.enroll(enrollInput({ provider, credential: { mode: 'reference' } })), + ); + expect(result.agent.id).not.toBe(seeded.agent.id); + expect(await credentialsFor(OWNER, provider)).toHaveLength(1); + }); + + // ── §5.4 harness refusals, both codes ──────────────────────────────────── + + it('refuses a syntactically invalid harness as validation_failed and a registry miss as precondition_failed', async () => { + const blank = await repo.enroll(enrollInput({ harness: ' ' })); + expectFail(blank, 'validation_failed'); + const miss = await repo.enroll(enrollInput({ harness: 'well-formed-but-unregistered' })); + expectFail(miss, 'precondition_failed'); + + currentUserId = OWNER; + const httpBlank = await http.post('/api/enrollment/agents').send({ + harness: '', + name: 'H', + model: 'm', + provider: 'p', + credential: { mode: 'reference' }, + idempotencyKey: randomUUID(), + }); + expect(httpBlank.status).toBe(400); + }); + + // ── §5.5 idempotency set (contract 3 §4.3) ─────────────────────────────── + + it('actor-bound replay returns the recorded outcome and executes nothing new', async () => { + const input = enrollInput(); + const first = expectOk(await repo.enroll(input)); + const replay = expectOk(await repo.enroll({ ...input, correlationId: randomUUID() })); + expect(replay.agent.id).toBe(first.agent.id); + + expect(await agentsNamed(input.name)).toHaveLength(1); + expect(await fenceForKey(input.idempotencyKey)).toHaveLength(1); + const events = await eventsForAgent(first.agent.id); + expect(events.filter((e) => e.eventType === 'agent.enrolled')).toHaveLength(1); + // A passing replay appends exactly the non-mutation access event. + const replayed = events.filter((e) => e.eventType === 'agent.enrollment.replayed'); + expect(replayed).toHaveLength(1); + expect((replayed[0]?.payload as { fenceId?: string }).fenceId).toBeDefined(); + }); + + it('payload-digest mismatch on a recorded key refuses with the single bounded conflict shape', async () => { + const input = enrollInput(); + expectOk(await repo.enroll(input)); + const mismatch = await repo.enroll({ ...input, name: `${input.name} CHANGED` }); + const failure = expectFail(mismatch, 'conflict'); + expect(failure.message).toBe('idempotency conflict'); + }); + + it('replay-mode and scope mismatches on the recorded fence each refuse as the same constant conflict', async () => { + const modeInput = enrollInput(); + expectOk(await repo.enroll(modeInput)); + await handle.db + .update(agentIdempotencyFence) + .set({ replayMode: 'shared' }) + .where(eq(agentIdempotencyFence.idempotencyKey, modeInput.idempotencyKey)); + const modeFailure = expectFail(await repo.enroll(modeInput), 'conflict'); + + const scopeInput = enrollInput(); + expectOk(await repo.enroll(scopeInput)); + await handle.db + .update(agentIdempotencyFence) + .set({ authorizationScope: 'some-other-scope' }) + .where(eq(agentIdempotencyFence.idempotencyKey, scopeInput.idempotencyKey)); + const scopeFailure = expectFail(await repo.enroll(scopeInput), 'conflict'); + + expect(modeFailure.message).toBe(scopeFailure.message); + }); + + it('a different actor replaying an actor-bound key is refused conflict, learning nothing', async () => { + const input = enrollInput(); + expectOk(await repo.enroll(input)); + const failure = expectFail(await repo.enroll({ ...input, actorId: STRANGER }), 'conflict'); + expect(failure.message).toBe('idempotency conflict'); + }); + + it('a replay is re-authorized fresh: revoked target authority refuses instead of replaying', async () => { + const input = enrollInput(); + const first = expectOk(await repo.enroll(input)); + // Simulate the legacy CRUD DELETE path removing the outcome agent: the + // submitter no longer holds read authority on the referenced row. + await handle.db.delete(agents).where(eq(agents.id, first.agent.id)); + expectFail(await repo.enroll(input), 'conflict'); + }); + + it('a shared replay-mode declaration is refused validation_failed with nothing executed and no fence row', async () => { + const input = enrollInput({ replayMode: 'shared' }); + expectFail(await repo.enroll(input), 'validation_failed'); + expect(await agentsNamed(input.name)).toHaveLength(0); + expect(await fenceForKey(input.idempotencyKey)).toHaveLength(0); + + currentUserId = OWNER; + const key = randomUUID(); + const res = await http.post('/api/enrollment/agents').send({ + harness: HARNESS, + name: 'Shared Probe', + model: 'm', + provider: 'p', + credential: { mode: 'reference' }, + idempotencyKey: key, + replayMode: 'shared', + }); + expect(res.status).toBe(400); + expect(await fenceForKey(key)).toHaveLength(0); + }); + + it('two concurrent same-key submissions produce exactly one mutation, the loser resolving as a replay', async () => { + const input = enrollInput(); + const [a, b] = await Promise.all([ + repo.enroll(input), + repo.enroll({ ...input, correlationId: randomUUID() }), + ]); + const okA = expectOk(a); + const okB = expectOk(b); + expect(okA.agent.id).toBe(okB.agent.id); + expect(await agentsNamed(input.name)).toHaveLength(1); + expect(await fenceForKey(input.idempotencyKey)).toHaveLength(1); + const events = await eventsForAgent(okA.agent.id); + expect(events.filter((e) => e.eventType === 'agent.enrolled')).toHaveLength(1); + expect(events.filter((e) => e.eventType === 'agent.enrollment.replayed')).toHaveLength(1); + }); + + // ── §5.6 same-tx atomicity fault injection ─────────────────────────────── + + it('rolls everything back on failure at each write point — no orphan credential survives', async () => { + const injectionPoints = [ + 'writeSealedCredential', + 'insertAgentRow', + 'insertFenceRow', + 'appendEvent', + 'insertOutboxRow', + ] as const; + + for (const point of injectionPoints) { + const input = enrollInput(); + const spy = vi.spyOn(repo, point).mockImplementationOnce(() => { + throw new Error(`injected ${point} fault`); + }); + try { + const result = await repo.enroll(input); + expectFail(result, 'internal_fault'); + expect(await agentsNamed(input.name)).toHaveLength(0); + expect(await fenceForKey(input.idempotencyKey)).toHaveLength(0); + // Injection at fence/audit/outbox fires AFTER the sealed credential + // write's statement ran — the rollback must leave no orphan row. + expect(await credentialsFor(OWNER, input.provider)).toHaveLength(0); + } finally { + spy.mockRestore(); + } + } + }); + + // ── §5.8 is_system closure ─────────────────────────────────────────────── + + it('rejects an is_system injection attempt at the DTO boundary', async () => { + currentUserId = OWNER; + const key = randomUUID(); + const res = await http.post('/api/enrollment/agents').send({ + harness: HARNESS, + name: 'System Probe', + model: 'm', + provider: 'p', + credential: { mode: 'reference' }, + idempotencyKey: key, + isSystem: true, + }); + expect(res.status).toBe(400); + expect(await fenceForKey(key)).toHaveLength(0); + }); + + // ── §5.9 correlation + no-existence-oracle ─────────────────────────────── + + it('carries a submitted correlation id into the result, the audit event, and the outbox record', async () => { + const correlationId = randomUUID(); + const input = enrollInput({ correlationId }); + const result = expectOk(await repo.enroll(input)); + expect(result.correlationId).toBe(correlationId); + const events = await eventsForAgent(result.agent.id); + expect(events).toHaveLength(1); + expect(events[0]?.correlationId).toBe(correlationId); + const outboxRows = await handle.db + .select() + .from(agentOutbox) + .where(eq(agentOutbox.eventId, events[0]?.id as string)); + expect(outboxRows).toHaveLength(1); + expect(outboxRows[0]?.correlationId).toBe(correlationId); + + // Refusals carry the correlation envelope too (contract 5 §4.3). + const refusal = expectFail( + await repo.enroll({ ...input, name: 'changed name', correlationId }), + 'conflict', + ); + expect(refusal.correlationId).toBe(correlationId); + }); + + it('agent.enrollment.get returns owner and admin reads with the correlation envelope, no idempotency key', async () => { + const enrolled = expectOk(await repo.enroll(enrollInput())); + const correlationId = randomUUID(); + const asOwner = expectOk(await repo.getEnrollment(OWNER, enrolled.agent.id, correlationId)); + expect(asOwner.correlationId).toBe(correlationId); + expect(asOwner.agent.id).toBe(enrolled.agent.id); + const asAdmin = expectOk(await repo.getEnrollment(ADMIN, enrolled.agent.id)); + expect(asAdmin.correlationId).toMatch(/^[0-9a-f-]{36}$/); + + currentUserId = OWNER; + const wire = randomUUID(); + const res = await http.get(`/api/enrollment/agents/${enrolled.agent.id}?correlationId=${wire}`); + expect(res.status).toBe(200); + expect((res.body as { correlationId: string }).correlationId).toBe(wire); + }); + + it('no existence oracle: unauthorized get of a real agent and get of a missing id are indistinguishable', async () => { + const enrolled = expectOk(await repo.enroll(enrollInput())); + + currentUserId = STRANGER; + const unauthorized = await http.get(`/api/enrollment/agents/${enrolled.agent.id}`); + const missing = await http.get(`/api/enrollment/agents/${randomUUID()}`); + expect(unauthorized.status).toBe(404); + expect(missing.status).toBe(404); + const strip = (body: Record): Record => + Object.fromEntries(Object.entries(body).filter(([key]) => key !== 'correlationId')); + expect(strip(unauthorized.body as Record)).toEqual( + strip(missing.body as Record), + ); + }); + + // ── §5.11 fail-closed ──────────────────────────────────────────────────── + + it('fails closed as internal_fault when the store is unreachable, with no fallback write', async () => { + const before = (await handle.db.select().from(agents)).length; + const txSpy = vi.spyOn(handle.db, 'transaction').mockImplementationOnce(() => { + throw new Error('injected store outage'); + }); + try { + expectFail(await repo.enroll(enrollInput()), 'internal_fault'); + } finally { + txSpy.mockRestore(); + } + const selectSpy = vi.spyOn(handle.db, 'select').mockImplementationOnce(() => { + throw new Error('injected store outage'); + }); + try { + expectFail(await repo.getEnrollment(OWNER, randomUUID()), 'internal_fault'); + } finally { + selectSpy.mockRestore(); + } + expect((await handle.db.select().from(agents)).length).toBe(before); + }); +}); diff --git a/apps/gateway/src/enrollment/enrollment.controller.ts b/apps/gateway/src/enrollment/enrollment.controller.ts new file mode 100644 index 00000000..c6b8b422 --- /dev/null +++ b/apps/gateway/src/enrollment/enrollment.controller.ts @@ -0,0 +1,61 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { CurrentUser } from '../auth/current-user.decorator.js'; +import { EnrollAgentDto, GetEnrollmentQueryDto } from './enrollment.dto.js'; +import { EnrollmentRepository } from './enrollment.repository.js'; +import { EnrollmentService } from './enrollment.service.js'; + +/** + * The agent enrollment command family's closed HTTP surface (design + * docs/plans/2026-08-29-agent-enrollment-command-design.md §3): one command, + * one query. Authentication failures are the guard's (401); everything else + * is the repository's closed enum mapped by EnrollmentService. + */ +@Controller('api/enrollment') +@UseGuards(AuthGuard) +export class EnrollmentController { + constructor( + private readonly repository: EnrollmentRepository, + private readonly service: EnrollmentService, + ) {} + + /** agent.enroll (§3.1). */ + @Post('agents') + async enroll(@CurrentUser() user: { id: string }, @Body() dto: EnrollAgentDto) { + return this.service.unwrap( + await this.repository.enroll({ + actorId: user.id, + harness: dto.harness, + name: dto.name, + persona: dto.persona ?? null, + model: dto.model, + provider: dto.provider, + credential: dto.credential, + idempotencyKey: dto.idempotencyKey, + correlationId: dto.correlationId, + replayMode: dto.replayMode, + }), + ); + } + + /** agent.enrollment.get (§3.2): owner-or-admin; unauthorized and missing fold to one not_found. */ + @Get('agents/:id') + async getEnrollment( + @CurrentUser() user: { id: string }, + @Param('id', ParseUUIDPipe) id: string, + @Query() query: GetEnrollmentQueryDto, + ) { + return this.service.unwrap( + await this.repository.getEnrollment(user.id, id, query.correlationId), + ); + } +} diff --git a/apps/gateway/src/enrollment/enrollment.dto.ts b/apps/gateway/src/enrollment/enrollment.dto.ts new file mode 100644 index 00000000..c8dd5fe9 --- /dev/null +++ b/apps/gateway/src/enrollment/enrollment.dto.ts @@ -0,0 +1,107 @@ +import { Type } from 'class-transformer'; +import { + IsIn, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, + ValidateIf, + ValidateNested, +} from 'class-validator'; + +/** + * Agent enrollment command DTOs (design + * docs/plans/2026-08-29-agent-enrollment-command-design.md §3.1/§3.2, + * contract 5 §4.1 typed boundary). + * + * The global ValidationPipe runs with whitelist + forbidNonWhitelisted, so + * closure is contract surface here exactly as in the hierarchy DTOs: + * - EnrollAgentDto declares NO isSystem field — `is_system` is never + * settable through this command (design §3.1 rule 4); the pipe refuses it. + * - replayMode admits ONLY 'actor-bound': `shared` is seed-only (contract 3 + * §4.3), so a shared declaration is refused `validation_failed` at the + * boundary, executes nothing, and records no fence row (design §3.1). + * Every class here must be registered in PIPE_GUARDED_DTOS so the boot-time + * assertion proves the pipe sees the decorators. + */ + +/** + * Credential input, discriminated on `mode` (design §3.1): + * - `{ mode: 'reference' }` — a stored credential for (actor, provider) + * must already exist; `type`/`value` must be ABSENT (the repository + * refuses a reference that smuggles a value). + * - `{ mode: 'intake', type: 'api_key', value }` — the value is sealed + * into the credential store inside the enrollment transaction and is + * never echoed anywhere (§3.1 rule 1). + */ +export class EnrollCredentialDto { + @IsIn(['reference', 'intake']) + mode!: 'reference' | 'intake'; + + @ValidateIf((o: EnrollCredentialDto) => o.mode === 'intake') + @IsIn(['api_key']) + type?: 'api_key'; + + @ValidateIf((o: EnrollCredentialDto) => o.mode === 'intake') + @IsString() + @MinLength(1) + @MaxLength(4096) + value?: string; +} + +export class EnrollAgentDto { + /** Registered harness name; a well-formed name missing from the registry is `precondition_failed`. */ + @IsString() + @MinLength(1) + @MaxLength(200) + harness!: string; + + @IsString() + @MinLength(1) + @MaxLength(200) + name!: string; + + /** Stored as the agent's system prompt; null/absent leaves it unset. */ + @IsOptional() + @IsString() + @MaxLength(20000) + persona?: string | null; + + /** Provider-qualified model id. */ + @IsString() + @MinLength(1) + @MaxLength(200) + model!: string; + + /** Names the credential's provider. */ + @IsString() + @MinLength(1) + @MaxLength(200) + provider!: string; + + @ValidateNested() + @Type(() => EnrollCredentialDto) + credential!: EnrollCredentialDto; + + /** REQUIRED — contract 3 §4.3, ratified into contract 5 §4 via §7 item 4. */ + @IsUUID() + idempotencyKey!: string; + + /** Optional; generated when absent (contract 5 §4.3). */ + @IsOptional() + @IsUUID() + correlationId?: string; + + /** Only 'actor-bound' is admissible on this family — see module doc. */ + @IsOptional() + @IsIn(['actor-bound']) + replayMode?: 'actor-bound'; +} + +/** Query envelope for agent.enrollment.get (design §3.2): correlation only, no idempotency key. */ +export class GetEnrollmentQueryDto { + @IsOptional() + @IsUUID() + correlationId?: string; +} diff --git a/apps/gateway/src/enrollment/enrollment.module.ts b/apps/gateway/src/enrollment/enrollment.module.ts new file mode 100644 index 00000000..4b3df3d5 --- /dev/null +++ b/apps/gateway/src/enrollment/enrollment.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { HarnessModule } from '../harness/harness.module.js'; +import { EnrollmentController } from './enrollment.controller.js'; +import { EnrollmentRepository } from './enrollment.repository.js'; +import { EnrollmentService } from './enrollment.service.js'; + +/** + * Agent enrollment command family (M4-4b; design + * docs/plans/2026-08-29-agent-enrollment-command-design.md). Imports + * HarnessModule for the live harness registry — the validation source for + * the `harness` field (a well-formed name the registry does not know is a + * precondition failure). EnrollmentRepository is the family's sole writer; + * every mutation runs fence-check → mutate → audit + outbox in one + * transaction. + */ +@Module({ + imports: [HarnessModule], + controllers: [EnrollmentController], + providers: [EnrollmentRepository, EnrollmentService], + exports: [EnrollmentRepository], +}) +export class EnrollmentModule {} diff --git a/apps/gateway/src/enrollment/enrollment.repository.ts b/apps/gateway/src/enrollment/enrollment.repository.ts new file mode 100644 index 00000000..11a90994 --- /dev/null +++ b/apps/gateway/src/enrollment/enrollment.repository.ts @@ -0,0 +1,538 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { seal } from '@mosaicstack/auth'; +import { + agentAuditEvents, + agentIdempotencyFence, + agentOutbox, + agents, + and, + eq, + providerCredentials, + users, + type Db, +} from '@mosaicstack/db'; +import { DB } from '../database/database.module.js'; +import type { HarnessRegistry } from '../harness/harness.registry.js'; +import { HARNESS_REGISTRY } from '../harness/harness.tokens.js'; + +/** + * Agent enrollment command repository (design + * docs/plans/2026-08-29-agent-enrollment-command-design.md §3; contract 5 §4 + * envelope; contract 3 §4.3 idempotency fence, ratified via §7 item 4). + * + * The ONLY writer of the enrollment family's tables (`agent_audit_events`, + * `agent_outbox`, `agent_idempotency_fence`) and the only path that sets + * `agents.harness`/`agents.enrolled_at`. Every enroll runs one transaction: + * fence check → (replay | credential handling → agent insert → fence insert → + * audit event + outbox), so state, fence, event, and outbox commit or roll + * back together (§3.1 rule 6). + * + * Authorization (v1, §3.1 rule 4) is the AuthGuard-authenticated actor — no + * hierarchy grant is consulted because v1 enrollment binds no hierarchy node. + * The recorded fence authorization scope is therefore the constant + * platform-user identity domain (§3.1 rule 5). + * + * Never-echo (§3.1 rule 1): the credential value reaches exactly one sink — + * the sealed store write — and appears in no result, audit payload, outbox + * row, or log line. Log lines here carry correlation ids and error names + * only, never request fields. + * + * The single-write helper methods (writeSealedCredential, insertAgentRow, + * insertFenceRow, appendEvent, insertOutboxRow) are ordinary decomposition; + * the atomicity witnesses (§5.6) spy on them to inject faults at each write + * point without any test-only production switch. + */ + +export const ENROLLMENT_OPERATION = 'agent.enroll'; +/** §3.1 rule 5: v1 authorization is grant-free, so the scope is the authenticated-user identity domain. */ +const AUTHORIZATION_SCOPE = 'platform-user'; +/** The single bounded collision shape (§3.1 rule 5): constant, identifying no record. */ +const CONFLICT_MESSAGE = 'idempotency conflict'; +/** One fixed message for every not_found cause — missing and unauthorized are indistinguishable (§3.2). */ +const NOT_FOUND_MESSAGE = 'agent not found'; + +/** Closed per-family error enum (§3.3). 401 is produced by AuthGuard; 403 folds to not_found (§3.2). */ +export type EnrollmentErrorCode = + | 'validation_failed' + | 'authentication_failed' + | 'authorization_refused' + | 'not_found' + | 'conflict' + | 'precondition_failed' + | 'internal_fault'; + +export interface EnrollmentFailure { + readonly ok: false; + readonly error: EnrollmentErrorCode; + readonly message: string; + /** Refusals carry the correlation id too (contract 5 §4.3 end-to-end traceability). */ + readonly correlationId: string; +} + +export type EnrollmentResult = + | ({ readonly ok: true; readonly correlationId: string } & T) + | EnrollmentFailure; + +/** The persisted agent row; the table stores no credential material (§3.1 rule 1). */ +export interface EnrolledAgentView { + readonly id: string; + readonly name: string; + readonly provider: string; + readonly model: string; + readonly status: string; + readonly harness: string | null; + readonly persona: string | null; + readonly ownerId: string | null; + readonly enrolledAt: string | null; + readonly createdAt: string; +} + +export interface EnrollCredentialInput { + readonly mode: 'reference' | 'intake'; + readonly type?: 'api_key'; + readonly value?: string; +} + +export interface EnrollAgentInput { + readonly actorId: string; + readonly harness: string; + readonly name: string; + readonly persona?: string | null; + readonly model: string; + readonly provider: string; + readonly credential: EnrollCredentialInput; + readonly idempotencyKey: string; + readonly correlationId?: string; + /** Defense in depth below the DTO: anything but 'actor-bound' is refused (seed-only rule). */ + readonly replayMode?: string; +} + +type Tx = Pick; +type AgentRow = typeof agents.$inferSelect; +type FenceRow = typeof agentIdempotencyFence.$inferSelect; + +/** Raised inside the transaction when the fence insert lost a same-key race (§3.1 rule 5 concurrency). */ +class ConcurrentEnrollmentError extends Error { + constructor() { + super('concurrent enrollment lost the fence race'); + this.name = 'ConcurrentEnrollmentError'; + } +} + +function agentView(row: AgentRow): EnrolledAgentView { + return { + id: row.id, + name: row.name, + provider: row.provider, + model: row.model, + status: row.status, + harness: row.harness, + persona: row.systemPrompt, + ownerId: row.ownerId, + enrolledAt: row.enrolledAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + }; +} + +/** Key-order-independent serialization (jsonb precedent in hierarchy-audit). */ +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value !== null && typeof value === 'object') { + const record = value as Record; + const body = Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(','); + return `{${body}}`; + } + return JSON.stringify(value); +} + +interface NormalizedEnrollment { + readonly actorId: string; + readonly harness: string; + readonly name: string; + readonly persona: string | null; + readonly model: string; + readonly provider: string; + readonly credential: EnrollCredentialInput; + readonly idempotencyKey: string; + readonly correlationId: string; + readonly digest: string; +} + +/** + * Canonicalized-payload digest (§3.1 rule 5). The input EXCLUDES the + * credential value by construction: it covers mode and declared type only — + * plaintext never reaches the hash. + */ +function digestOf( + input: Omit, +): string { + const canonical = canonicalJson({ + harness: input.harness, + name: input.name, + persona: input.persona, + model: input.model, + provider: input.provider, + credential: { mode: input.credential.mode, type: input.credential.type ?? null }, + }); + return createHash('sha256').update(canonical).digest('hex'); +} + +@Injectable() +export class EnrollmentRepository { + private readonly logger = new Logger(EnrollmentRepository.name); + + constructor( + @Inject(DB) private readonly db: Db, + @Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry, + ) {} + + async enroll(input: EnrollAgentInput): Promise> { + const correlationId = input.correlationId ?? randomUUID(); + const fail = (error: EnrollmentErrorCode, message: string): EnrollmentFailure => ({ + ok: false, + error, + message, + correlationId, + }); + + const harness = input.harness.trim(); + const name = input.name.trim(); + if (harness.length === 0) return fail('validation_failed', 'harness must be non-empty'); + if (name.length === 0 || name.length > 200) { + return fail('validation_failed', 'name must be non-empty and at most 200 characters'); + } + if (input.replayMode !== undefined && input.replayMode !== 'actor-bound') { + // Seed-only rule (contract 3 §4.3): refused with nothing executed and no fence row. + return fail('validation_failed', 'replayMode must be actor-bound'); + } + if (input.credential.mode === 'reference') { + if (input.credential.type !== undefined || input.credential.value !== undefined) { + return fail('validation_failed', 'a reference credential carries no type or value'); + } + } else if ( + input.credential.type !== 'api_key' || + typeof input.credential.value !== 'string' || + input.credential.value.length === 0 + ) { + return fail('validation_failed', 'an intake credential requires type api_key and a value'); + } + // Syntactic validity ends above; a well-formed name the live registry + // does not know is a precondition failure (§3.1 table). + if (!this.registry.has(harness)) { + return fail('precondition_failed', 'harness is not registered'); + } + + const normalized: NormalizedEnrollment = { + actorId: input.actorId, + harness, + name, + persona: input.persona ?? null, + model: input.model, + provider: input.provider, + credential: input.credential, + idempotencyKey: input.idempotencyKey, + correlationId, + digest: digestOf({ + harness, + name, + persona: input.persona ?? null, + model: input.model, + provider: input.provider, + credential: input.credential, + }), + }; + + // Two attempts: a fence-race loser's transaction rolls back and the retry + // resolves through the replay path against the winner's committed row — + // or executes afresh if the winner aborted (§3.1 rule 5 concurrency). A + // unique-violation race never surfaces as an unhandled internal fault. + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + return await this.db.transaction(async (tx) => this.enrollTx(tx, normalized)); + } catch (error) { + if (error instanceof ConcurrentEnrollmentError && attempt === 0) continue; + if (error instanceof ConcurrentEnrollmentError) { + return fail('conflict', CONFLICT_MESSAGE); + } + // §4.4 fail-closed: whatever broke, the transaction rolled back and + // the refusal is the internal-fault class — no fallback write or read. + this.logger.error( + `agent.enroll failed closed (correlation=${correlationId}): ${ + error instanceof Error ? error.name : 'unknown error' + }`, + ); + return fail('internal_fault', 'internal fault'); + } + } + return fail('internal_fault', 'internal fault'); + } + + private async enrollTx( + tx: Tx, + input: NormalizedEnrollment, + ): Promise> { + const fence = await this.fenceFor(tx, input.idempotencyKey); + if (fence) return this.replay(tx, fence, input); + + if (input.credential.mode === 'reference') { + // §3.1 rule 3: the reference must resolve for (actor, provider). + const existing = await tx + .select({ id: providerCredentials.id }) + .from(providerCredentials) + .where( + and( + eq(providerCredentials.userId, input.actorId), + eq(providerCredentials.provider, input.provider), + ), + ) + .limit(1); + if (existing.length === 0) { + return { + ok: false, + error: 'precondition_failed', + message: 'credential reference does not resolve', + correlationId: input.correlationId, + }; + } + } else { + // §3.1 rule 2: sealed-store write inside THIS transaction — a later + // failure rolls it back, leaving no orphan credential. + await this.writeSealedCredential( + tx, + input.actorId, + input.provider, + input.credential.value as string, + ); + } + + const agentRow = await this.insertAgentRow(tx, input); + const fenceRow = await this.insertFenceRow(tx, input, agentRow.id); + if (!fenceRow) { + // A same-(operation, key) winner committed first; abandon our writes. + throw new ConcurrentEnrollmentError(); + } + await this.appendEvent(tx, { + eventType: 'agent.enrolled', + actorId: input.actorId, + agentId: agentRow.id, + correlationId: input.correlationId, + // §3.1 rule 6 payload: harness, provider, name, credentialMode — no credential material. + payload: { + harness: input.harness, + provider: input.provider, + name: input.name, + credentialMode: input.credential.mode, + }, + }); + return { ok: true, correlationId: input.correlationId, agent: agentView(agentRow) }; + } + + /** + * Replay path (§3.1 rule 5): a fresh submission of a recorded + * (operation, key). The actor is re-authorized exactly as a fresh + * submission (v1: authenticated actor — the guard already ran); then mode, + * scope, digest, and recorded-actor equality; then target-result read + * authority (owner or admin) on the referenced agent. ANY failure refuses + * with the single bounded conflict shape — constant, identifying no record. + * A passing replay executes nothing and appends only the non-mutation + * access event (with its outbox record — one outbox row per event). + */ + private async replay( + tx: Tx, + fence: FenceRow, + input: NormalizedEnrollment, + ): Promise> { + const collision: EnrollmentFailure = { + ok: false, + error: 'conflict', + message: CONFLICT_MESSAGE, + correlationId: input.correlationId, + }; + if (fence.replayMode !== 'actor-bound') return collision; + if (fence.authorizationScope !== AUTHORIZATION_SCOPE) return collision; + if (fence.payloadDigest !== input.digest) return collision; + if (fence.actorId !== input.actorId) return collision; + + const rows = await tx.select().from(agents).where(eq(agents.id, fence.outcomeAgentId)).limit(1); + const agentRow = rows[0]; + if (!agentRow) return collision; + const authorized = + agentRow.ownerId === input.actorId || (await this.isPlatformAdmin(tx, input.actorId)); + if (!authorized) return collision; + + await this.appendEvent(tx, { + eventType: 'agent.enrollment.replayed', + actorId: input.actorId, + agentId: agentRow.id, + correlationId: input.correlationId, + payload: { fenceId: fence.id }, + }); + return { ok: true, correlationId: input.correlationId, agent: agentView(agentRow) }; + } + + /** + * agent.enrollment.get (§3.2): owner-or-admin read. Unauthorized and + * missing fold to the same not_found wire shape (no existence oracle). + */ + async getEnrollment( + actorId: string, + agentId: string, + correlationId?: string, + ): Promise> { + const resolvedCorrelation = correlationId ?? randomUUID(); + try { + const rows = await this.db.select().from(agents).where(eq(agents.id, agentId)).limit(1); + const row = rows[0]; + if (row) { + const authorized = + row.ownerId === actorId || (await this.isPlatformAdmin(this.db, actorId)); + if (authorized) { + return { ok: true, correlationId: resolvedCorrelation, agent: agentView(row) }; + } + } + return { + ok: false, + error: 'not_found', + message: NOT_FOUND_MESSAGE, + correlationId: resolvedCorrelation, + }; + } catch (error) { + this.logger.error( + `agent.enrollment.get failed closed (correlation=${resolvedCorrelation}): ${ + error instanceof Error ? error.name : 'unknown error' + }`, + ); + return { + ok: false, + error: 'internal_fault', + message: 'internal fault', + correlationId: resolvedCorrelation, + }; + } + } + + private async fenceFor(tx: Tx, idempotencyKey: string): Promise { + const rows = await tx + .select() + .from(agentIdempotencyFence) + .where( + and( + eq(agentIdempotencyFence.operation, ENROLLMENT_OPERATION), + eq(agentIdempotencyFence.idempotencyKey, idempotencyKey), + ), + ) + .limit(1); + return rows[0] ?? null; + } + + private async isPlatformAdmin(tx: Tx, actorId: string): Promise { + const rows = await tx + .select({ role: users.role }) + .from(users) + .where(eq(users.id, actorId)) + .limit(1); + return rows[0]?.role === 'admin'; + } + + /** + * Sealed intake write, mirroring ProviderCredentialsService.store semantics + * (seal-at-rest, one row per (userId, provider)) but on the enrollment + * transaction (§3.1 rule 2). The plaintext exists only in this frame. + */ + async writeSealedCredential( + tx: Tx, + userId: string, + provider: string, + value: string, + ): Promise { + const encryptedValue = seal(value); + await tx + .insert(providerCredentials) + .values({ userId, provider, credentialType: 'api_key', encryptedValue, metadata: null }) + .onConflictDoUpdate({ + target: [providerCredentials.userId, providerCredentials.provider], + set: { + credentialType: 'api_key', + encryptedValue, + metadata: null, + updatedAt: new Date(), + }, + }); + } + + async insertAgentRow(tx: Tx, input: NormalizedEnrollment): Promise { + const rows = await tx + .insert(agents) + .values({ + name: input.name, + provider: input.provider, + model: input.model, + harness: input.harness, + systemPrompt: input.persona, + // §3.1 rule 4: owner is the authenticated actor; is_system stays default false. + ownerId: input.actorId, + enrolledAt: new Date(), + }) + .returning(); + const row = rows[0]; + if (!row) throw new Error('agent insert returned no row'); + return row; + } + + async insertFenceRow( + tx: Tx, + input: NormalizedEnrollment, + outcomeAgentId: string, + ): Promise { + const rows = await tx + .insert(agentIdempotencyFence) + .values({ + operation: ENROLLMENT_OPERATION, + idempotencyKey: input.idempotencyKey, + actorId: input.actorId, + authorizationScope: AUTHORIZATION_SCOPE, + payloadDigest: input.digest, + replayMode: 'actor-bound', + outcomeAgentId, + }) + .onConflictDoNothing() + .returning(); + return rows[0] ?? null; + } + + /** Append one audit event and its outbox record on the caller's transaction (one outbox row per event). */ + async appendEvent( + tx: Tx, + input: { + eventType: 'agent.enrolled' | 'agent.enrollment.replayed'; + actorId: string; + agentId: string; + correlationId: string; + payload: Record; + causationId?: string; + }, + ): Promise { + const inserted = await tx + .insert(agentAuditEvents) + .values({ + eventType: input.eventType, + actorId: input.actorId, + agentId: input.agentId, + correlationId: input.correlationId, + causationId: input.causationId ?? null, + payload: input.payload, + }) + .returning(); + const event = inserted[0]; + if (!event) throw new Error('agent audit event insert returned no row'); + await this.insertOutboxRow(tx, event.id, input.correlationId); + } + + async insertOutboxRow(tx: Tx, eventId: string, correlationId: string): Promise { + await tx.insert(agentOutbox).values({ eventId, correlationId }); + } +} diff --git a/apps/gateway/src/enrollment/enrollment.service.ts b/apps/gateway/src/enrollment/enrollment.service.ts new file mode 100644 index 00000000..0d800b9f --- /dev/null +++ b/apps/gateway/src/enrollment/enrollment.service.ts @@ -0,0 +1,45 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import type { + EnrollmentErrorCode, + EnrollmentFailure, + EnrollmentResult, +} from './enrollment.repository.js'; + +/** + * Maps enrollment result unions onto the closed HTTP status set (design + * docs/plans/2026-08-29-agent-enrollment-command-design.md §3.3, contract 5 + * §4.2). Every refusal body carries the correlation id (contract 5 §4.3 + * end-to-end traceability) alongside the enum code. `not_found` carries one + * fixed message for every cause — missing agent and unauthorized caller are + * indistinguishable on the wire (§3.2). + */ +const HTTP_STATUS: Record = { + validation_failed: HttpStatus.BAD_REQUEST, + authentication_failed: HttpStatus.UNAUTHORIZED, + authorization_refused: HttpStatus.FORBIDDEN, + not_found: HttpStatus.NOT_FOUND, + conflict: HttpStatus.CONFLICT, + precondition_failed: HttpStatus.UNPROCESSABLE_ENTITY, + internal_fault: HttpStatus.INTERNAL_SERVER_ERROR, +}; + +@Injectable() +export class EnrollmentService { + unwrap(result: EnrollmentResult): { ok: true; correlationId: string } & T { + if (result.ok) return result; + throw this.toException(result); + } + + private toException(failure: EnrollmentFailure): HttpException { + const status = HTTP_STATUS[failure.error]; + return new HttpException( + { + statusCode: status, + error: failure.error, + message: failure.message, + correlationId: failure.correlationId, + }, + status, + ); + } +} diff --git a/apps/gateway/src/validation-pipe-check.ts b/apps/gateway/src/validation-pipe-check.ts index 2492daaf..7e8a8597 100644 --- a/apps/gateway/src/validation-pipe-check.ts +++ b/apps/gateway/src/validation-pipe-check.ts @@ -13,6 +13,11 @@ import { TransferEstateDto, TransferPlatformProjectDto, } from './hierarchy/hierarchy.dto.js'; +import { + EnrollAgentDto, + EnrollCredentialDto, + GetEnrollmentQueryDto, +} from './enrollment/enrollment.dto.js'; /** * Boot-time self-check: the global ValidationPipe must be able to SEE the @@ -105,6 +110,31 @@ export const PIPE_GUARDED_DTOS: Array<{ target: ChangeGrantDto, properties: ['role', 'idempotencyKey'], }, + { + name: 'EnrollAgentDto', + target: EnrollAgentDto, + properties: [ + 'harness', + 'name', + 'persona', + 'model', + 'provider', + 'credential', + 'idempotencyKey', + 'correlationId', + 'replayMode', + ], + }, + { + name: 'EnrollCredentialDto', + target: EnrollCredentialDto, + properties: ['mode', 'type', 'value'], + }, + { + name: 'GetEnrollmentQueryDto', + target: GetEnrollmentQueryDto, + properties: ['correlationId'], + }, ]; export class PipeMetatypeCheckError extends Error { diff --git a/packages/mosaic/src/commands/agent-enrollment-command.spec.ts b/packages/mosaic/src/commands/agent-enrollment-command.spec.ts new file mode 100644 index 00000000..40e2301a --- /dev/null +++ b/packages/mosaic/src/commands/agent-enrollment-command.spec.ts @@ -0,0 +1,218 @@ +import { Command } from 'commander'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { registerAgentCommand, runEnroll, runGetEnrollment } from './agent.js'; +import { enrollAgent, fetchEnrollment } from '../tui/gateway-api.js'; + +/** + * CLI-parity witness for the agent enrollment family (design + * docs/plans/2026-08-29-agent-enrollment-command-design.md §5 item 10; + * contract 5 §4.5): `mosaic agent enroll` / `mosaic agent enrollment ` + * invoke the same gateway commands with the same request/result/error + * contracts the web client uses. The gateway side of the same routes is + * witnessed in apps/gateway/src/enrollment/enrollment-commands.integration.test.ts. + */ + +const gateway = 'https://gateway.example.test'; +const auth = { gateway, cookie: 'session=test' }; + +const agentBody = { + id: '3fca4f6a-1111-4222-8333-444455556666', + name: 'Nova', + provider: 'anthropic', + model: 'claude-test', + status: 'idle', + harness: 'fake-harness', + persona: null, + ownerId: 'user-1', + enrolledAt: '2026-08-29T00:00:00.000Z', + createdAt: '2026-08-29T00:00:00.000Z', +}; + +const okResponse = (correlationId: string) => + new Response(JSON.stringify({ ok: true, correlationId, agent: agentBody }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +describe('agent enrollment CLI registration', (): void => { + it('registers enroll and enrollment subcommands under `mosaic agent`', () => { + const program = new Command(); + const cmd = registerAgentCommand(program); + const names = cmd.commands.map((c) => c.name()); + expect(names).toContain('enroll'); + expect(names).toContain('enrollment'); + }); + + it('the enroll subcommand exposes no argv flag that carries a credential value', () => { + const program = new Command(); + const cmd = registerAgentCommand(program); + const enroll = cmd.commands.find((c) => c.name() === 'enroll'); + expect(enroll).toBeDefined(); + const flags = (enroll as Command).options.map((o) => o.flags); + // --credential selects the MODE only; the intake value arrives via stdin. + expect(flags).toContain('--credential '); + for (const flag of flags) { + expect(flag).not.toMatch(/value|key |api-key/i); + } + }); +}); + +describe('agent.enroll CLI parity', (): void => { + it('posts the §3.1 request shape for reference mode and surfaces the typed result', async () => { + const fetchMock = vi.fn(async () => okResponse('corr-ref')); + vi.stubGlobal('fetch', fetchMock); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runEnroll(auth, { + gateway, + harness: 'fake-harness', + name: 'Nova', + model: 'claude-test', + provider: 'anthropic', + credential: 'reference', + idempotencyKey: '9c1a26be-0000-4000-8000-000000000001', + correlationId: 'corr-ref', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(`${gateway}/api/enrollment/agents`); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ + harness: 'fake-harness', + name: 'Nova', + model: 'claude-test', + provider: 'anthropic', + credential: { mode: 'reference' }, + idempotencyKey: '9c1a26be-0000-4000-8000-000000000001', + correlationId: 'corr-ref', + }); + expect(log.mock.calls.flat().join('\n')).toContain(agentBody.id); + expect(process.exitCode).toBeUndefined(); + }); + + it('intake mode reads the credential from the injected stdin reader, never argv', async () => { + const fetchMock = vi.fn(async () => okResponse('corr-intake')); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runEnroll( + auth, + { + gateway, + harness: 'fake-harness', + name: 'Nova', + model: 'claude-test', + provider: 'anthropic', + credential: 'intake', + idempotencyKey: '9c1a26be-0000-4000-8000-000000000002', + }, + async () => 'stdin-provided-key', + ); + + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + const body = JSON.parse(init.body as string) as { credential: Record }; + expect(body.credential).toEqual({ + mode: 'intake', + type: 'api_key', + value: 'stdin-provided-key', + }); + }); + + it('an empty intake credential refuses locally with no gateway call', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + await runEnroll( + auth, + { + gateway, + harness: 'fake-harness', + name: 'Nova', + model: 'claude-test', + provider: 'anthropic', + credential: 'intake', + }, + async () => '', + ); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('preserves the gateway refusal contract (closed error enum + correlation) in the CLI error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ + statusCode: 409, + error: 'conflict', + message: 'idempotency conflict', + correlationId: 'corr-409', + }), + { status: 409, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ); + + await expect( + enrollAgent(gateway, auth.cookie, { + harness: 'fake-harness', + name: 'Nova', + model: 'claude-test', + provider: 'anthropic', + credential: { mode: 'reference' }, + idempotencyKey: '9c1a26be-0000-4000-8000-000000000003', + }), + ).rejects.toThrow( + 'Failed to enroll agent (409): {"statusCode":409,"error":"conflict",' + + '"message":"idempotency conflict","correlationId":"corr-409"}', + ); + }); +}); + +describe('agent.enrollment.get CLI parity', (): void => { + it('reads one enrollment with the correlation envelope on the query string', async () => { + const fetchMock = vi.fn(async () => okResponse('corr-get')); + vi.stubGlobal('fetch', fetchMock); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runGetEnrollment(auth, agentBody.id, 'corr-get'); + + const [url] = fetchMock.mock.calls[0] as unknown as [string]; + expect(url).toBe(`${gateway}/api/enrollment/agents/${agentBody.id}?correlationId=corr-get`); + expect(log.mock.calls.flat().join('\n')).toContain('corr-get'); + }); + + it('preserves the folded not_found contract in the CLI error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ + statusCode: 404, + error: 'not_found', + message: 'agent not found', + correlationId: 'corr-404', + }), + { status: 404, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ); + + await expect(fetchEnrollment(gateway, auth.cookie, agentBody.id)).rejects.toThrow( + 'Failed to get enrollment (404): {"statusCode":404,"error":"not_found",' + + '"message":"agent not found","correlationId":"corr-404"}', + ); + }); +}); diff --git a/packages/mosaic/src/commands/agent.ts b/packages/mosaic/src/commands/agent.ts index 09de4b1f..6c3e4b04 100644 --- a/packages/mosaic/src/commands/agent.ts +++ b/packages/mosaic/src/commands/agent.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import type { Command } from 'commander'; import { registerFleetAgentCommands, type FleetCommandDeps } from './fleet.js'; import { withAuth } from './with-auth.js'; @@ -9,8 +10,10 @@ import { deleteAgentConfig, fetchProjects, fetchProviders, + enrollAgent, + fetchEnrollment, } from '../tui/gateway-api.js'; -import type { AgentConfigInfo } from '../tui/gateway-api.js'; +import type { AgentConfigInfo, EnrolledAgentInfo } from '../tui/gateway-api.js'; function formatAgent(a: AgentConfigInfo): string { const sys = a.isSystem ? ' [system]' : ''; @@ -75,11 +78,140 @@ export function registerAgentCommand(program: Command, fleetDeps: FleetCommandDe }, ); + registerEnrollmentCommands(cmd); registerFleetAgentCommands(cmd, fleetDeps); return cmd; } +// ── Agent enrollment (design docs/plans/2026-08-29-agent-enrollment-command-design.md §3; +// CLI parity bound by contract 5 §4.5) ── + +export interface EnrollCommandOptions { + gateway: string; + harness: string; + name: string; + model: string; + provider: string; + persona?: string; + credential: string; + idempotencyKey?: string; + correlationId?: string; + replayMode?: string; +} + +/** + * Read an intake credential value from stdin. Never accepted via argv — a + * process argument is world-readable in `ps` for the process lifetime. + */ +export async function readCredentialFromStdin(): Promise { + if (process.stdin.isTTY) { + console.error('Enter API key, then press Enter and Ctrl-D:'); + } + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks) + .toString('utf8') + .replace(/\r?\n$/, ''); +} + +function showEnrollment(correlationId: string, agent: EnrolledAgentInfo): void { + console.log(` ID: ${agent.id}`); + console.log(` Name: ${agent.name}`); + console.log(` Harness: ${agent.harness ?? '—'}`); + console.log(` Provider: ${agent.provider}`); + console.log(` Model: ${agent.model}`); + console.log(` Status: ${agent.status}`); + console.log(` Owner: ${agent.ownerId ?? '—'}`); + console.log(` Enrolled: ${agent.enrolledAt ?? '—'}`); + console.log(` Correlation: ${correlationId}`); +} + +export async function runEnroll( + auth: { gateway: string; cookie: string }, + opts: EnrollCommandOptions, + readSecret: () => Promise = readCredentialFromStdin, +): Promise { + if (opts.credential !== 'reference' && opts.credential !== 'intake') { + console.error(`Unknown credential mode "${opts.credential}" (use reference or intake).`); + process.exitCode = 1; + return; + } + + let credential: { mode: 'reference' } | { mode: 'intake'; type: 'api_key'; value: string }; + if (opts.credential === 'intake') { + const value = await readSecret(); + if (!value) { + console.error('Intake credential requires a non-empty API key on stdin.'); + process.exitCode = 1; + return; + } + credential = { mode: 'intake', type: 'api_key', value }; + } else { + credential = { mode: 'reference' }; + } + + const result = await enrollAgent(auth.gateway, auth.cookie, { + harness: opts.harness, + name: opts.name, + model: opts.model, + provider: opts.provider, + ...(opts.persona !== undefined ? { persona: opts.persona } : {}), + credential, + idempotencyKey: opts.idempotencyKey ?? randomUUID(), + ...(opts.correlationId !== undefined ? { correlationId: opts.correlationId } : {}), + ...(opts.replayMode !== undefined ? { replayMode: opts.replayMode } : {}), + }); + + console.log(`Agent "${result.agent.name}" enrolled.\n`); + showEnrollment(result.correlationId, result.agent); +} + +export async function runGetEnrollment( + auth: { gateway: string; cookie: string }, + agentId: string, + correlationId?: string, +): Promise { + const result = await fetchEnrollment(auth.gateway, auth.cookie, agentId, correlationId); + showEnrollment(result.correlationId, result.agent); +} + +export function registerEnrollmentCommands(cmd: Command): void { + cmd + .command('enroll') + .description('Enroll an agent through the gateway enrollment command (agent.enroll)') + .requiredOption('--harness ', 'Harness the agent runs on (must be registered)') + .requiredOption('--name ', 'Agent display name') + .requiredOption('--model ', 'Model identifier') + .requiredOption('--provider ', 'Provider the credential belongs to') + .option('--persona ', 'Agent persona / system prompt') + .option( + '--credential ', + 'Credential mode: "reference" (already stored) or "intake" (API key read from stdin, never argv)', + 'reference', + ) + .option('--idempotency-key ', 'Idempotency key (generated when omitted)') + .option('--correlation-id ', 'Correlation id to carry through the audit trail') + .option('--replay-mode ', 'Idempotency replay mode (actor-bound)') + .action(async (opts: Omit) => { + const parent = cmd.opts<{ gateway: string }>(); + const auth = await withAuth(parent.gateway); + await runEnroll(auth, { ...opts, gateway: parent.gateway }); + }); + + cmd + .command('enrollment ') + .description('Read one enrolled agent (agent.enrollment.get; owner or admin)') + .option('--correlation-id ', 'Correlation id to carry through the read') + .action(async (agentId: string, opts: { correlationId?: string }) => { + const parent = cmd.opts<{ gateway: string }>(); + const auth = await withAuth(parent.gateway); + await runGetEnrollment(auth, agentId, opts.correlationId); + }); +} + async function resolveAgent( gateway: string, cookie: string, diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index 468d5c5d..71c8a09e 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -173,6 +173,8 @@ describe('registerFleetCommand', () => { expect(agent!.options.map((option) => option.long)).toContain('--list'); expect(agent!.commands.map((command) => command.name()).sort()).toEqual([ 'comms-block', + 'enroll', + 'enrollment', 'reset', 'roster', 'send', diff --git a/packages/mosaic/src/tui/gateway-api.ts b/packages/mosaic/src/tui/gateway-api.ts index 7e169bdb..547801e1 100644 --- a/packages/mosaic/src/tui/gateway-api.ts +++ b/packages/mosaic/src/tui/gateway-api.ts @@ -562,6 +562,74 @@ export async function fetchInteractionHealth(gatewayUrl: string): Promise(res, 'Failed to get interaction readiness'); } +// ── Agent Enrollment types (design docs/plans/2026-08-29-agent-enrollment-command-design.md §3) ── + +export interface EnrolledAgentInfo { + id: string; + name: string; + provider: string; + model: string; + status: string; + harness: string | null; + persona: string | null; + ownerId: string | null; + enrolledAt: string | null; + createdAt: string; +} + +export interface EnrollAgentRequest { + harness: string; + name: string; + persona?: string; + model: string; + provider: string; + credential: { mode: 'reference' } | { mode: 'intake'; type: 'api_key'; value: string }; + idempotencyKey: string; + correlationId?: string; + replayMode?: string; +} + +export interface EnrollmentOutcome { + ok: true; + correlationId: string; + agent: EnrolledAgentInfo; +} + +// ── Agent Enrollment endpoints ── + +/** + * agent.enroll. The gateway's typed refusal body (closed error enum + + * correlationId) is preserved verbatim in the thrown error, so the CLI + * surfaces the same contract the web client receives. + */ +export async function enrollAgent( + gatewayUrl: string, + sessionCookie: string, + data: EnrollAgentRequest, +): Promise { + const res = await fetch(`${gatewayUrl}/api/enrollment/agents`, { + method: 'POST', + headers: jsonHeaders(sessionCookie, gatewayUrl), + body: JSON.stringify(data), + }); + return handleResponse(res, 'Failed to enroll agent'); +} + +/** agent.enrollment.get: owner-or-admin read of one enrolled agent. */ +export async function fetchEnrollment( + gatewayUrl: string, + sessionCookie: string, + agentId: string, + correlationId?: string, +): Promise { + const params = correlationId ? `?${new URLSearchParams({ correlationId }).toString()}` : ''; + const res = await fetch( + `${gatewayUrl}/api/enrollment/agents/${encodeURIComponent(agentId)}${params}`, + { headers: headers(sessionCookie, gatewayUrl) }, + ); + return handleResponse(res, 'Failed to get enrollment'); +} + // ── Conversation Message types ── export interface ConversationMessage {