feat(gateway,cli): agent enrollment command family (M4-4b) (#1483)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: fred <[email protected]>
This commit was merged in pull request #1483.
This commit is contained in:
@@ -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 <id>`
|
||||
* 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 <mode>');
|
||||
for (const flag of flags) {
|
||||
expect(flag).not.toMatch(/value|key <secret>|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<string, string> };
|
||||
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"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user