feat(gateway,cli): agent enrollment command family (M4-4b)
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Implements the command module for the M4-4-0 design (docs/plans/2026-08-29-agent-enrollment-command-design.md) over the M4-4a schema (migration 0021): - EnrollmentModule: agent.enroll (POST /api/enrollment/agents) and agent.enrollment.get (GET /api/enrollment/agents/:id), closed error enum, correlation envelope on every result and refusal. - EnrollmentRepository as the family's sole writer: fence-check -> mutate -> audit + outbox in one transaction; actor-bound idempotency replay with fresh authorization; intake credentials sealed (AES-256-GCM) into provider_credentials, never echoed anywhere; reference mode resolves the actor's stored credential; harness validated against the live registry (fail-closed in prod until adapters register). - CLI parity (contract 5 s4.5): mosaic agent enroll / enrollment subcommands; intake API key read from stdin, never argv. - 19 integration witnesses covering design s5 items 1-9 and 11 (never-echo, sealed single-copy, reference resolution, harness refusal codes, the s4.3 idempotency set incl. concurrent same-key, five-point fault-injection atomicity, zero-mutation, is_system closure, correlation + no-existence-oracle, fail-closed) plus an 8-test CLI parity spec (item 10).
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"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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<string> = readCredentialFromStdin,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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 <id>', 'Harness the agent runs on (must be registered)')
|
||||
.requiredOption('--name <name>', 'Agent display name')
|
||||
.requiredOption('--model <model>', 'Model identifier')
|
||||
.requiredOption('--provider <provider>', 'Provider the credential belongs to')
|
||||
.option('--persona <text>', 'Agent persona / system prompt')
|
||||
.option(
|
||||
'--credential <mode>',
|
||||
'Credential mode: "reference" (already stored) or "intake" (API key read from stdin, never argv)',
|
||||
'reference',
|
||||
)
|
||||
.option('--idempotency-key <uuid>', 'Idempotency key (generated when omitted)')
|
||||
.option('--correlation-id <uuid>', 'Correlation id to carry through the audit trail')
|
||||
.option('--replay-mode <mode>', 'Idempotency replay mode (actor-bound)')
|
||||
.action(async (opts: Omit<EnrollCommandOptions, 'gateway'>) => {
|
||||
const parent = cmd.opts<{ gateway: string }>();
|
||||
const auth = await withAuth(parent.gateway);
|
||||
await runEnroll(auth, { ...opts, gateway: parent.gateway });
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('enrollment <agentId>')
|
||||
.description('Read one enrolled agent (agent.enrollment.get; owner or admin)')
|
||||
.option('--correlation-id <uuid>', '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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -562,6 +562,74 @@ export async function fetchInteractionHealth(gatewayUrl: string): Promise<unknow
|
||||
return handleResponse<unknown>(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<EnrollmentOutcome> {
|
||||
const res = await fetch(`${gatewayUrl}/api/enrollment/agents`, {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<EnrollmentOutcome>(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<EnrollmentOutcome> {
|
||||
const params = correlationId ? `?${new URLSearchParams({ correlationId }).toString()}` : '';
|
||||
const res = await fetch(
|
||||
`${gatewayUrl}/api/enrollment/agents/${encodeURIComponent(agentId)}${params}`,
|
||||
{ headers: headers(sessionCookie, gatewayUrl) },
|
||||
);
|
||||
return handleResponse<EnrollmentOutcome>(res, 'Failed to get enrollment');
|
||||
}
|
||||
|
||||
// ── Conversation Message types ──
|
||||
|
||||
export interface ConversationMessage {
|
||||
|
||||
Reference in New Issue
Block a user