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:
@@ -25,6 +25,7 @@ import { HarnessModule } from './harness/harness.module.js';
|
|||||||
import { ReloadModule } from './reload/reload.module.js';
|
import { ReloadModule } from './reload/reload.module.js';
|
||||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||||
import { HierarchyModule } from './hierarchy/hierarchy.module.js';
|
import { HierarchyModule } from './hierarchy/hierarchy.module.js';
|
||||||
|
import { EnrollmentModule } from './enrollment/enrollment.module.js';
|
||||||
import { QueueModule } from './queue/queue.module.js';
|
import { QueueModule } from './queue/queue.module.js';
|
||||||
import { FederationModule } from './federation/federation.module.js';
|
import { FederationModule } from './federation/federation.module.js';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
@@ -67,6 +68,7 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
|
|||||||
ReloadModule,
|
ReloadModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
HierarchyModule,
|
HierarchyModule,
|
||||||
|
EnrollmentModule,
|
||||||
...(federationEnabled ? [FederationModule] : []),
|
...(federationEnabled ? [FederationModule] : []),
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
|
|||||||
@@ -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<typeof supertest>;
|
||||||
|
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> = {}): 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<T>(result: EnrollmentResult<T>): { ok: true; correlationId: string } & T {
|
||||||
|
if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectFail<T>(
|
||||||
|
result: EnrollmentResult<T>,
|
||||||
|
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<void> => {
|
||||||
|
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<NestFastifyApplication>(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<void> => {
|
||||||
|
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<string, unknown>): Record<string, unknown> =>
|
||||||
|
Object.fromEntries(Object.entries(body).filter(([key]) => key !== 'correlationId'));
|
||||||
|
expect(strip(unauthorized.body as Record<string, unknown>)).toEqual(
|
||||||
|
strip(missing.body as Record<string, unknown>),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── §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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<T> =
|
||||||
|
| ({ 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<Db, 'insert' | 'select' | 'update' | 'delete'>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<NormalizedEnrollment, 'actorId' | 'idempotencyKey' | 'correlationId' | 'digest'>,
|
||||||
|
): 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<EnrollmentResult<{ agent: EnrolledAgentView }>> {
|
||||||
|
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<EnrollmentResult<{ agent: EnrolledAgentView }>> {
|
||||||
|
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<EnrollmentResult<{ agent: EnrolledAgentView }>> {
|
||||||
|
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<EnrollmentResult<{ agent: EnrolledAgentView }>> {
|
||||||
|
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<FenceRow | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<AgentRow> {
|
||||||
|
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<FenceRow | null> {
|
||||||
|
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<string, unknown>;
|
||||||
|
causationId?: string;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await tx.insert(agentOutbox).values({ eventId, correlationId });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<EnrollmentErrorCode, HttpStatus> = {
|
||||||
|
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<T>(result: EnrollmentResult<T>): { 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ import {
|
|||||||
TransferEstateDto,
|
TransferEstateDto,
|
||||||
TransferPlatformProjectDto,
|
TransferPlatformProjectDto,
|
||||||
} from './hierarchy/hierarchy.dto.js';
|
} 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
|
* 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,
|
target: ChangeGrantDto,
|
||||||
properties: ['role', 'idempotencyKey'],
|
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 {
|
export class PipeMetatypeCheckError extends Error {
|
||||||
|
|||||||
@@ -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 type { Command } from 'commander';
|
||||||
import { registerFleetAgentCommands, type FleetCommandDeps } from './fleet.js';
|
import { registerFleetAgentCommands, type FleetCommandDeps } from './fleet.js';
|
||||||
import { withAuth } from './with-auth.js';
|
import { withAuth } from './with-auth.js';
|
||||||
@@ -9,8 +10,10 @@ import {
|
|||||||
deleteAgentConfig,
|
deleteAgentConfig,
|
||||||
fetchProjects,
|
fetchProjects,
|
||||||
fetchProviders,
|
fetchProviders,
|
||||||
|
enrollAgent,
|
||||||
|
fetchEnrollment,
|
||||||
} from '../tui/gateway-api.js';
|
} 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 {
|
function formatAgent(a: AgentConfigInfo): string {
|
||||||
const sys = a.isSystem ? ' [system]' : '';
|
const sys = a.isSystem ? ' [system]' : '';
|
||||||
@@ -75,11 +78,140 @@ export function registerAgentCommand(program: Command, fleetDeps: FleetCommandDe
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
registerEnrollmentCommands(cmd);
|
||||||
registerFleetAgentCommands(cmd, fleetDeps);
|
registerFleetAgentCommands(cmd, fleetDeps);
|
||||||
|
|
||||||
return cmd;
|
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(
|
async function resolveAgent(
|
||||||
gateway: string,
|
gateway: string,
|
||||||
cookie: string,
|
cookie: string,
|
||||||
|
|||||||
@@ -173,6 +173,8 @@ describe('registerFleetCommand', () => {
|
|||||||
expect(agent!.options.map((option) => option.long)).toContain('--list');
|
expect(agent!.options.map((option) => option.long)).toContain('--list');
|
||||||
expect(agent!.commands.map((command) => command.name()).sort()).toEqual([
|
expect(agent!.commands.map((command) => command.name()).sort()).toEqual([
|
||||||
'comms-block',
|
'comms-block',
|
||||||
|
'enroll',
|
||||||
|
'enrollment',
|
||||||
'reset',
|
'reset',
|
||||||
'roster',
|
'roster',
|
||||||
'send',
|
'send',
|
||||||
|
|||||||
@@ -562,6 +562,74 @@ export async function fetchInteractionHealth(gatewayUrl: string): Promise<unknow
|
|||||||
return handleResponse<unknown>(res, 'Failed to get interaction readiness');
|
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 ──
|
// ── Conversation Message types ──
|
||||||
|
|
||||||
export interface ConversationMessage {
|
export interface ConversationMessage {
|
||||||
|
|||||||
Reference in New Issue
Block a user