feat(gateway,cli): agent enrollment command family (M4-4b)
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:
fred
2026-08-29 21:11:24 -05:00
parent 143ba0f57a
commit 34f4b34702
12 changed files with 1734 additions and 1 deletions
@@ -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"}',
);
});
});
+133 -1
View File
@@ -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',
+68
View File
@@ -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 {