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