Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34f4b34702 | ||
|
|
143ba0f57a | ||
|
|
ee815a72b1 | ||
|
|
94d626dff9 | ||
|
|
ee6c842918 | ||
|
|
ba3b854d50 | ||
|
|
c7a7fd07cc | ||
|
|
5399c6b7e7 | ||
|
|
e09b8783b4 | ||
|
|
abb0c93601 | ||
|
|
e67cced273 | ||
|
|
635cb1f666 |
@@ -25,6 +25,7 @@ import { HarnessModule } from './harness/harness.module.js';
|
||||
import { ReloadModule } from './reload/reload.module.js';
|
||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||
import { HierarchyModule } from './hierarchy/hierarchy.module.js';
|
||||
import { EnrollmentModule } from './enrollment/enrollment.module.js';
|
||||
import { QueueModule } from './queue/queue.module.js';
|
||||
import { FederationModule } from './federation/federation.module.js';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
@@ -67,6 +68,7 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
|
||||
ReloadModule,
|
||||
WorkspaceModule,
|
||||
HierarchyModule,
|
||||
EnrollmentModule,
|
||||
...(federationEnabled ? [FederationModule] : []),
|
||||
],
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -108,11 +108,13 @@ export class MissionsController {
|
||||
) {
|
||||
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
// dto.status is deliberately not forwarded: mission_tasks.status is
|
||||
// write-prohibited through the N-1 window (SHARED-CONTRACT §5.1 phase 1);
|
||||
// the repo strips it as well.
|
||||
return this.brain.missionTasks.create({
|
||||
missionId,
|
||||
taskId: dto.taskId,
|
||||
userId: user.id,
|
||||
status: dto.status,
|
||||
description: dto.description,
|
||||
notes: dto.notes,
|
||||
pr: dto.pr,
|
||||
|
||||
@@ -77,6 +77,12 @@ export class CreateMissionTaskDto {
|
||||
@IsUUID()
|
||||
taskId?: string;
|
||||
|
||||
/**
|
||||
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
|
||||
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
|
||||
* The field stays declared because the global ValidationPipe runs with
|
||||
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsIn(taskStatuses)
|
||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
@@ -102,6 +108,12 @@ export class UpdateMissionTaskDto {
|
||||
@IsUUID()
|
||||
taskId?: string;
|
||||
|
||||
/**
|
||||
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
|
||||
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
|
||||
* The field stays declared because the global ValidationPipe runs with
|
||||
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsIn(taskStatuses)
|
||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
TransferEstateDto,
|
||||
TransferPlatformProjectDto,
|
||||
} 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
|
||||
@@ -105,6 +110,31 @@ export const PIPE_GUARDED_DTOS: Array<{
|
||||
target: ChangeGrantDto,
|
||||
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 {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
| [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md) | rc.16 direct-Drizzle current storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README commented/user-guide executable forms fail before masking and source-consistency rejects runner-delegation copy; held future bootstrap → TLS/roles → run → verify → readiness; plus prior production boundary, pgvector owner, attestation, inventory, manifests, DDL classifier, TLS/bootstrap, activation, and certification contract; foundation prerequisite of KBN-100 and real-role gate before KBN-105 |
|
||||
| [`KBN-101-ENVELOPE-A.md`](./KBN-101-ENVELOPE-A.md) | KBN-101 Envelope A (v6) — RATIFIED, part of the frozen SSOT: rc.20 declarative sink-RBAC + per-role connection-selection + RLS `WITH CHECK`/`USING` write-source + `FORCE ROW LEVEL SECURITY` + sink-resident `task_status_write_override`; adds owner card KBN-101-10 + responsibility-widenings; authority Jason B1 + Mos OPTION A/Q1/Q2 |
|
||||
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
|
||||
| [`P0-MAP-CURRENCY-2026-08-29.md`](./P0-MAP-CURRENCY-2026-08-29.md) | REQ-MIG-001 lane-opening verification: SHARED-CONTRACT §5 field map re-verified byte-identical at `next` @ `abb0c936`; workspaces/audit-pattern refinements; measured `mission_tasks.status` writer inventory and the pre-expand stop-write work item |
|
||||
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
|
||||
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
|
||||
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
kind: verification
|
||||
status: active
|
||||
---
|
||||
|
||||
# P0 Field-Map Currency Verification — 2026-08-29
|
||||
|
||||
**Purpose:** REQ-MIG-001 (native-kanban-sot.md §5) accepts only when "P0 publishes
|
||||
the current `origin/main` field-by-field expand/backfill/compatibility/switch/contract
|
||||
map before any schema lane starts." That map exists: [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md)
|
||||
§5, inspected at `packages/db/src/schema.ts` @ `e72388b2cbfe400842fe940fa6cabf984ed43711`
|
||||
(2026-07-13). The M4-3 schema lane (expand migration 0021+) now opens against the
|
||||
integration trunk `next`. This document re-verifies the map's currency at the
|
||||
lane-opening head and records the measured pre-expand writer inventory. It amends
|
||||
nothing normative in SHARED-CONTRACT.md; where the two disagree, SHARED-CONTRACT.md
|
||||
wins.
|
||||
|
||||
## 1. Currency verification (measured)
|
||||
|
||||
- Map pin: `e72388b2cbfe400842fe940fa6cabf984ed43711` (2026-07-13, `main`).
|
||||
- Lane-opening head: `abb0c936011c7f6b8c0bcc90a20a865d5e8a40e9` (`origin/next`,
|
||||
2026-08-29).
|
||||
- Measurement: `git diff e72388b2 abb0c936 -- packages/db/src/schema.ts` reports
|
||||
**300 insertions, 0 deletions** — no existing declaration changed.
|
||||
- The additions: the new declarations `logicalAgentConnectorLeases`,
|
||||
`connectorLeaseAuditLog`, and the hierarchy layer (`companies`, `estates`,
|
||||
`platformProjects`, `workspaces`, `hierarchyGrants`, `hierarchyAuditEvents`,
|
||||
`hierarchyOutbox`, plus their enums and constant arrays); a nullable `issuer`
|
||||
column on the unmapped BetterAuth `accounts` table (shipped as
|
||||
`drizzle/0017_accounts_issuer.sql`); and expanded `drizzle-orm` imports
|
||||
(`sql`, `AnyPgColumn`, `unique`, `check`, `bigint`). None touch a mapped
|
||||
source.
|
||||
- Stronger literal fact: REQ-MIG-001's acceptance names `origin/main`. Measured
|
||||
pin → `origin/main` (`7102ccb9`, 2026-08-13): **63 insertions, 0 deletions**
|
||||
for `schema.ts`, and `origin/main` is an ancestor of `abb0c936`. The map is
|
||||
therefore current at `origin/main` itself, and at the trunk head beyond it.
|
||||
|
||||
**Consequence:** every source column mapped in SHARED-CONTRACT.md §5.4 —
|
||||
`teams`/`team_members`, `projects`, `missions`, `tasks`, `mission_tasks`,
|
||||
`agents`, fleet `backlog` — is byte-identical to the declaration the map
|
||||
inspected. The field map is current as written. No row changes.
|
||||
|
||||
## 2. Refinements available since the pin (context, not map changes)
|
||||
|
||||
1. **The `workspaces` table exists.** The map predates contract 1's hierarchy
|
||||
layer; its "bootstrap workspace" backfill step now has a shipped target:
|
||||
`workspaces` (uuid PK, chained under platform projects per
|
||||
`docs/requirements/hierarchy-schema.md`; hierarchy core in
|
||||
`drizzle/0018_clean_cobalt_man.sql`, audit/outbox in
|
||||
`0019_volatile_killraven.sql`, visibility in
|
||||
`0020_special_betty_brant.sql`). New `workspace_id` columns FK there.
|
||||
2. **The audit/outbox envelope pattern is shipped.** `hierarchyAuditEvents` +
|
||||
`hierarchyOutbox` implement same-transaction semantic event + outbox. The
|
||||
task lane's `task_events`/`task_outbox` mirror the pattern but are
|
||||
workspace-scoped with the composite `(workspace_id, id)` key required by
|
||||
§5.3 and REQ-SOT-004. The hierarchy tables are a pattern reference, never a
|
||||
shared store for task events.
|
||||
3. **Trunk designation.** The integration trunk is `next` (`.mosaic/repo.json`).
|
||||
§1 measures currency at both the literal `origin/main` REQ-MIG-001 names and
|
||||
the trunk head pinned above, so no reinterpretation of the acceptance text
|
||||
is needed.
|
||||
4. **Migration ownership.** SHARED-CONTRACT.md §6 assigns schema/migration
|
||||
ownership to the mission seat `coder2`. Seat identity is operational fleet
|
||||
state, not resolvable from this repository, and is outside this document's
|
||||
scope. The invariant §6 protects binds regardless of seat and is restated
|
||||
here as binding on the M4-3 schema lane: exactly one lane generates
|
||||
migrations at a time; expand is additive; no drop/rename/narrow; constraints
|
||||
validate before NOT NULL.
|
||||
|
||||
## 3. Pre-expand writer inventory (measured 2026-08-29 at `abb0c936`)
|
||||
|
||||
SHARED-CONTRACT.md §5.1 phase 1 requires an N-1 patch that stops
|
||||
`mission_tasks.status` as a write source, plus a writer inventory, before any
|
||||
expand DDL.
|
||||
|
||||
- **Sole authoring write path:** `packages/brain/src/mission-tasks.ts`
|
||||
`create`/`update` (Drizzle insert/update on `mission_tasks`), invoked by
|
||||
`apps/gateway/src/missions/missions.controller.ts`. `update` accepts
|
||||
`Partial<NewMissionTask>`, so `status` is writable through both DTOs today.
|
||||
The same module also exposes `remove`/`removeByMission` DELETE paths —
|
||||
immaterial to `status` writes, listed for inventory completeness.
|
||||
- **Storage-layer surfaces that touch the column without authoring it**
|
||||
(added 2026-08-29 after independent review of the phase-1 patch):
|
||||
`packages/storage/src/migrate-tier.ts` copies whole `mission_tasks` rows
|
||||
between storage tiers and must preserve the stored `status` verbatim — row
|
||||
transport, exempt from the write prohibition (stripping there would corrupt
|
||||
data inside the N-1 window). The generic table-keyed storage adapters
|
||||
(`adapters/postgres.ts`, `adapters/pglite.ts`) register `mission_tasks` in
|
||||
their table maps but have no caller that targets it: measured at this head,
|
||||
every runtime adapter caller passes a fixed collection constant
|
||||
(preferences/insights). Neither surface authors a new `status` value.
|
||||
- **Read-only consumers of `mission_tasks`:** federation verb services
|
||||
(`get-query.service.ts`, `list-query.service.ts`) select only. The MCP
|
||||
`brain_*` tools do not touch `mission_tasks` at all; `brain_create_task` /
|
||||
`brain_update_task` write the separately mapped `tasks` table, a legitimate
|
||||
N-1 writer through the compatibility window.
|
||||
- The ratified contract 5 decision
|
||||
(`docs/requirements/tool-gateway-mapping.md` §3.2, ruled 2026-08-27) freezes
|
||||
the legacy endpoints — including MCP `brain_*` task mutations — for new
|
||||
consumers, while existing consumers keep working until each surface's owning
|
||||
contract retires it. It does not stop existing writes.
|
||||
|
||||
**Standing work item:** the phase-1 stop-write patch (reject or ignore `status`
|
||||
on `mission_tasks` create/update) MUST land before the expand DDL of migration
|
||||
lane M4-3a. It is N-1-safe per the §5.4 row for `mission_tasks.status` (linked
|
||||
status is ignored; the column stays declared and readable through the whole
|
||||
N-1 window; retirement only after no readers).
|
||||
|
||||
## 4. Lane opening
|
||||
|
||||
With this verification merged, REQ-MIG-001's P0-map precondition is satisfied
|
||||
for the M4-3 schema lane at pinned head `abb0c936`. The ordered phases (§5.1),
|
||||
mission candidate-key DDL order (§5.2), audit/proposal DDL order (§5.3), field
|
||||
map (§5.4), and required migration tests (§5.5) bind as written. External
|
||||
import machinery (jarvis-brain/Vikunja shadow import, REQ-MIG-001) and client
|
||||
cutover (REQ-MIG-002) remain out of scope for M4-3; the legacy surface stays
|
||||
frozen for new consumers meanwhile (`tool-gateway-mapping.md` §3.2 decision).
|
||||
@@ -0,0 +1,315 @@
|
||||
---
|
||||
kind: spec
|
||||
status: active
|
||||
audience: developer
|
||||
---
|
||||
|
||||
# Agent Enrollment Command Family — v1 Design (M4-4-0)
|
||||
|
||||
Status: design note (implementation-facing; amends no contract).
|
||||
Authority chain: tool-gateway-mapping.md §3.1 rank-4 row + §4 envelope
|
||||
(ruled 2026-08-27), onboarding-wizard.md §3.5 (D11 minimal enrollment),
|
||||
custody-schema.md §5.2 at revision 13 (agent-grantee FK bound to the
|
||||
live `agents` table — a binding introduced at rev 4 and standing
|
||||
verbatim), PRD §9 D11. Where this note and a ratified contract disagree,
|
||||
the contract wins.
|
||||
|
||||
## 1. What the contracts bind (and what they leave open)
|
||||
|
||||
There is no standalone enrollment contract. The rank-4 family is defined
|
||||
by composition:
|
||||
|
||||
1. **Contract 5 §3.1 rank 4:** "Enroll one agent: harness, credential
|
||||
reference/API-key intake (values never echoed), name/persona,
|
||||
assignment scope (contract 3 §3.5)."
|
||||
2. **Contract 5 §4 — all five sub-clauses:** §4.1 typed request/result
|
||||
DTOs validated at the Gateway boundary (expected-version only where
|
||||
an owning contract defines one); §4.2 closed per-family error enum
|
||||
(validation, authentication, authorization, not-found, conflict,
|
||||
precondition, internal) with HTTP mappings; §4.3 audit linkage — the
|
||||
envelope contributes correlation: every request accepts/generates a
|
||||
correlation id, carried into the audit events **and returned in the
|
||||
result**, with no second audit stream; §4.4 fail-closed — an
|
||||
operation that cannot evaluate its authorization or reach its owning
|
||||
tool refuses, never degrading to a fallback read or direct data
|
||||
access; §4.5 CLI parity — the family MUST be invocable through the
|
||||
official CLI against the same Gateway commands with the same
|
||||
request/result/error contracts (a Gateway command without CLI
|
||||
exposure is a tracked conformance gap).
|
||||
**Idempotency keys are NOT contract 5 §4.3:** the idempotency-key
|
||||
envelope is contract 3 §4.3, ratified as a drafting addition to
|
||||
contract 5 §4's command envelope via contract 3 §7 item 4. Its fence
|
||||
and replay rules bind as written there; §3.1 rule 5 below designs to
|
||||
them.
|
||||
3. **Contract 3 §3.5:** the wizard's enrollment step is minimal (one
|
||||
harness, API-key login, agent name and persona — D11), uses ONLY this
|
||||
family, and is skippable. Wizard witness §6.10: a run that skips the
|
||||
step produces zero enrollment-family mutations.
|
||||
4. **Custody-schema §5.2 (rev 13; binding introduced at rev 4):**
|
||||
contract 7's agent-grantee FK references the live `agents` table
|
||||
(`agents.id`, uuid); an enrollment surface with its own table would
|
||||
force a contract-7 amendment.
|
||||
|
||||
**Assignment scope (open point, pinned here):** the rank-4 row cites
|
||||
contract 3 §3.5, which defines no assignment semantics; the PRD's full
|
||||
enrollment vision (Part I, Standalone flow) includes "account
|
||||
assignment", but the D11 v1 slice is exactly "one harness, API key,
|
||||
name/persona". v1 therefore scopes assignment to the two bindings the
|
||||
minimal slice already implies — the enrolling user becomes the agent's
|
||||
owner (`agents.owner_id`), and the credential reference names which of
|
||||
that user's stored provider credentials the agent uses. Richer
|
||||
assignment (multi-account, comms auto-enroll, workspace placement) is
|
||||
deferred with the rest of the PRD's full flow (D11); when a contract
|
||||
defines it, this family extends by ordinary amendment of the design.
|
||||
The deferral rests on contract 3 §3.5's explicit delegation of
|
||||
enrollment specifics to this family — not on reading the D11 list as
|
||||
exhaustive (it is not: the §3.1 `model`/`provider` fields are required
|
||||
by the live table's NOT NULL columns, though D11 does not name them).
|
||||
|
||||
## 2. Current state (measured 2026-08-29 at `origin/next` = `94d626df`)
|
||||
|
||||
- `agents` table (packages/db `schema.ts`): id uuid PK, name, provider,
|
||||
model, status enum, project_id (legacy `projects`, ON DELETE SET
|
||||
NULL), owner_id → users, system_prompt, allowed_tools, skills,
|
||||
is_system, config jsonb, timestamps. No harness column (provider and
|
||||
model describe the LLM backend, not the harness), no audit coupling.
|
||||
- Sole write path: `packages/brain/src/agents.ts` repository (the only
|
||||
module issuing `insert(agents)`), with three write consumers: the
|
||||
legacy `/api/agents` CRUD controller
|
||||
(`apps/gateway/src/agent/agent-configs.controller.ts`), the `/agent
|
||||
new` chat command (`apps/gateway/src/commands/command-executor.service.ts`
|
||||
→ `brain.agents.create`), and workspace bootstrap
|
||||
(`apps/gateway/src/workspace/project-bootstrap.service.ts`). All
|
||||
three keep serving existing consumers; none is touched by M4-4.
|
||||
- Sealed credential store exists: `ProviderCredentialsService`
|
||||
(apps/gateway/src/agent/) — one row per (userId, provider), values
|
||||
sealed at rest, decrypt server-side only, summaries never carry
|
||||
values.
|
||||
- Harness registry exists (`apps/gateway/src/harness/`), the validation
|
||||
source for the harness field.
|
||||
- Implementation pattern: the merged hierarchy module (M4-1) —
|
||||
transaction-scoped command context, in-tx authorization, discriminated
|
||||
result unions, same-transaction semantic audit event + transactional
|
||||
outbox, no-oracle not_found folding.
|
||||
|
||||
**F1 — contract-5 mapping note (disposition, not an amendment):**
|
||||
`/api/agents` appears nowhere in contract 5 — neither as a P0 row nor in
|
||||
the §3.2 legacy non-substitutes list (the ruled §3.2 freeze names
|
||||
specific endpoints, and `/api/agents` is not among them). The operative
|
||||
constraints are §3.3's amendment-only rule for new mapping rows and §5's
|
||||
closure rule: this design adds no new consumer to `/api/agents` and
|
||||
builds the rank-4 family as the P1 path for enrollment. Adding the
|
||||
missing P0 row is a contract amendment for a future S2 pass; nothing in
|
||||
M4-4 depends on it.
|
||||
|
||||
## 3. Command family surface (v1)
|
||||
|
||||
One command, one query. Module: `apps/gateway/src/enrollment/`
|
||||
(`enrollment.module.ts`), mirroring the hierarchy module's shape.
|
||||
|
||||
### 3.1 `agent.enroll` (mutation)
|
||||
|
||||
Request DTO (shared types package, class-validator at the boundary):
|
||||
|
||||
| Field | Type | Rule |
|
||||
| ---------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `harness` | string | syntactically invalid (empty/malformed) → `validation_failed`; well-formed but not in the harness registry → `precondition_failed` |
|
||||
| `correlationId` | string (uuid) | optional; generated when absent (contract 5 §4.3); carried into audit events and returned in the result |
|
||||
| `replayMode` | 'actor-bound' | optional, default `actor-bound`. `shared` is seed-only (contract 3 §4.3 binds it to the §3.4 canonical seed key set and "no other operation can carry a shared declaration"; §7 item 4 closes it); a `shared` declaration here is refused `validation_failed`, executes nothing, and records no fence row |
|
||||
| `name` | string | non-empty, trimmed, ≤ 200 chars |
|
||||
| `persona` | string \| null | optional; stored as the agent's system prompt |
|
||||
| `model` | string | non-empty (provider-qualified model id) |
|
||||
| `provider` | string | non-empty; names the credential's provider |
|
||||
| `credential` | discriminated union | `{ mode: 'reference' }` — a credential for (actor, provider) MUST already exist; `{ mode: 'intake', type: 'api_key', value: string }` — value is sealed into the credential store in the same flow |
|
||||
| `idempotencyKey` | string (uuid) | required (contract 3 §4.3, ratified into contract 5 §4 via contract 3 §7 item 4) |
|
||||
|
||||
Rules:
|
||||
|
||||
1. **Never echoed.** The credential value appears in no result DTO, no
|
||||
audit event, no outbox payload, and no log line. The result carries
|
||||
only `{ provider, credentialMode }`.
|
||||
2. **Intake = the existing sealed store, inside the transaction.**
|
||||
`intake` writes through the sealed-store path
|
||||
(`ProviderCredentialsService.store` semantics: seal-at-rest, upsert
|
||||
per (userId, provider)) **in the same transaction** as the agent
|
||||
insert — a failure after the credential write rolls everything back,
|
||||
leaving no orphan credential. Enrollment persists no second copy and
|
||||
no plaintext.
|
||||
3. **Reference must resolve.** `reference` with no stored credential for
|
||||
(actor, provider) refuses with `precondition_failed` (nothing is
|
||||
created).
|
||||
4. **Ownership.** `owner_id` = the authenticated actor. v1 authorization
|
||||
is AuthGuard-authenticated user; no hierarchy grant is required
|
||||
because v1 enrollment binds no hierarchy node (§1 assignment-scope
|
||||
pin). `is_system` is never settable through this command.
|
||||
5. **Idempotency fence (contract 3 §4.3, in full).** The command layer
|
||||
records, in a uniqueness-constrained fence table in the same
|
||||
transaction as the mutation and its audit event: the key, the
|
||||
operation identifier (`agent.enroll`), the acting principal, the
|
||||
authorization scope, a digest of the canonicalized request payload
|
||||
(the digest input EXCLUDES the credential value — it covers
|
||||
provider + credentialMode, never plaintext), the declared replay
|
||||
mode (always `actor-bound` for this family — the `shared` refusal
|
||||
in the table above means no shared fence row can exist here; the
|
||||
column is kept for envelope-shape fidelity and mode-mismatch
|
||||
collision checks), and a reference to the committed outcome (the
|
||||
agent id). The recorded **authorization scope** for this family is
|
||||
pinned to the acting principal's platform-user scope (v1
|
||||
authorization is grant-free per rule 4, so the scope is the
|
||||
authenticated-user identity domain — recorded so the §4.3
|
||||
scope-equality check has a defined value). Fence uniqueness is the
|
||||
pair (operation identifier, key). **Replay:** a submission whose
|
||||
(operation, key) is recorded is first authorized exactly as a fresh
|
||||
submission; then replay-mode, scope, and digest equality are
|
||||
checked (a mismatch on any — including scope — is a collision);
|
||||
then **target-result authorization** — the submitter must hold, at
|
||||
replay time, read authority on the referenced agent row under
|
||||
§3.2's rule (owner or admin) — plus recorded-actor equality
|
||||
(`actor-bound`). A passing replay executes nothing, returns the
|
||||
recorded outcome, and appends a replay access event (non-mutation
|
||||
audit class: accessing principal, current correlation id,
|
||||
fence-row reference). Any equality or authorization failure refuses
|
||||
with the single bounded `conflict` shape — constant, identifying no
|
||||
record — preserving the no-existence-oracle rule. **Concurrency
|
||||
(contract 3 §4.3's rule, ratified via §7 item 4):** two submissions
|
||||
with the same (operation, key) serialize on the fence's unique
|
||||
constraint — exactly one executes; the loser waits for the winner's
|
||||
transaction, and is then handled as a replay if it committed
|
||||
(through the full replay path above) or executes afresh if it
|
||||
aborted. A unique-violation race never surfaces as an unhandled
|
||||
internal fault.
|
||||
6. **Audit + outbox, same transaction.** Insert into `agents` +
|
||||
sealed credential write (intake mode) + fence row + semantic audit
|
||||
event (`agent.enrolled`: actor, agent id, harness, provider, name,
|
||||
credentialMode — no credential material) + outbox row commit
|
||||
atomically, hierarchy-pattern style. Audit rows reference the agent
|
||||
by **snapshot id, not FK** — mirroring the hierarchy audit tables'
|
||||
deliberate FK-free linkage so audit history survives agent deletion
|
||||
through the legacy CRUD DELETE path.
|
||||
|
||||
Result union: `enrolled { agent, correlationId }` | refusal from the
|
||||
§3.3 enum (refusals also carry the correlation id, per contract 5
|
||||
§4.3's end-to-end traceability). `agent` in the result is the persisted
|
||||
row minus nothing sensitive (the table stores no credential material).
|
||||
|
||||
### 3.2 `agent.enrollment.get` (query)
|
||||
|
||||
By agent id; actor must be the owner (or admin). Unauthorized and
|
||||
missing fold to the same `not_found` wire shape (contract 2
|
||||
no-existence-oracle rule, applied family-wide for uniformity).
|
||||
|
||||
The query carries the same non-state envelope as the mutation
|
||||
(contract 5 §4.3; contract 3's envelope reconciliation confirms closed
|
||||
query responses carry it): typed request DTO with an optional
|
||||
`correlationId` (generated when absent) and a typed result —
|
||||
`found { agent, correlationId }` | `not_found` (the folded shape,
|
||||
also carrying the correlation id). Queries take no idempotency key
|
||||
(the fence binds mutations).
|
||||
|
||||
### 3.3 Error enum (closed, §4.2)
|
||||
|
||||
`validation_failed` 400 · `authentication_failed` 401 ·
|
||||
`authorization_refused` 403 (owner-only paths; folded to `not_found`
|
||||
where §3.2 applies) · `not_found` 404 · `conflict` 409 (the single
|
||||
bounded idempotency refusal shape of §3.1 rule 5) · `precondition_failed`
|
||||
422 (unresolvable credential reference; well-formed harness not in the
|
||||
registry — syntactic invalidity is `validation_failed` per the §3.1
|
||||
table) · `internal_fault` 500 (also the §4.4 fail-closed class when the
|
||||
owning tool is unreachable; unauthorized-fallback behavior is
|
||||
prohibited).
|
||||
|
||||
## 4. Schema delta (migration 0021, additive-only)
|
||||
|
||||
Extend `agents` — no new agent table, preserving custody-schema §5.2's
|
||||
FK binding without amendment:
|
||||
|
||||
- `harness` text NULL — registered harness name; NULL for pre-existing
|
||||
rows (legacy rows predate the concept).
|
||||
- `enrolled_at` timestamptz NULL — set by `agent.enroll`; NULL marks a
|
||||
legacy (non-enrolled) row. No backfill: enrollment is a fact this
|
||||
command creates, not one to invent for existing rows.
|
||||
|
||||
New tables, mirroring the hierarchy audit/outbox pair (pattern reuse,
|
||||
separate store): `agent_audit_events` (append-only: id, event_type,
|
||||
actor id, agent id — snapshot value, no FK, per §3.1 rule 6 —
|
||||
correlation id, causation id, payload jsonb, created_at; per-agent
|
||||
ordering index), `agent_outbox` (hierarchy-outbox shape), and
|
||||
`agent_idempotency_fence` (contract 3 §4.3 shape: operation identifier,
|
||||
key, acting principal, authorization scope, canonicalized-payload
|
||||
digest, replay mode, committed-outcome reference (agent id), created_at;
|
||||
UNIQUE (operation identifier, key)). Persona reuses the existing
|
||||
`system_prompt` column; no version column (no ratified expected-version
|
||||
rule names `agents` — §4.1 binds only where the owning contract defines
|
||||
one).
|
||||
|
||||
Witnesses (real PostgreSQL, lane standard): append-only enforcement,
|
||||
same-tx atomicity (agent row + credential write + fence row + audit +
|
||||
outbox all-or-nothing under injected failure at multiple points,
|
||||
including after the credential write), fence uniqueness on
|
||||
(operation, key).
|
||||
|
||||
Sequencing: additive DDL via the same migration path as 0018–0020
|
||||
(hierarchy). The docs/native-kanban-sot/SHARED-CONTRACT.md §5.3 DDL
|
||||
gate binds the kanban lane's audit/proposal DDL, not this lane; if a
|
||||
pending operator ruling on migration sequencing changes mechanics
|
||||
lane-wide, re-check before generating 0021.
|
||||
|
||||
## 5. Witnesses the implementation slice must ship
|
||||
|
||||
1. Never-echo: enroll via `intake`, assert the value string is absent
|
||||
from the HTTP result, the audit row, the outbox payload, and captured
|
||||
logs.
|
||||
2. Sealed-store single-copy: after intake, the credential exists only in
|
||||
`provider_credentials` (sealed), and `agents` has no credential
|
||||
column at all.
|
||||
3. Reference-resolution refusal (`precondition_failed`, no row created).
|
||||
4. Harness refusals, both codes: syntactically invalid →
|
||||
`validation_failed`; well-formed registry miss →
|
||||
`precondition_failed` (against the live registry).
|
||||
5. Idempotency (contract 3 §4.3 set): actor-bound replay returns the
|
||||
recorded outcome and executes nothing (no new agent/audit/outbox
|
||||
mutation rows; a replay access event is appended); payload-digest
|
||||
mismatch, replay-mode mismatch, scope mismatch, and different-actor
|
||||
actor-bound replay each refuse with the single bounded `conflict`
|
||||
shape; a replay is re-authorized fresh (a submitter whose
|
||||
authorization was revoked since the original is refused, not
|
||||
replayed); a `shared` declaration on `agent.enroll` is refused
|
||||
`validation_failed` with nothing executed and no fence row
|
||||
recorded (seed-only rule); two concurrent same-(operation, key)
|
||||
submissions produce exactly one mutation, the loser resolving
|
||||
through the replay path (no unhandled unique-violation fault).
|
||||
6. Same-tx atomicity fault injection (agent / credential write / fence
|
||||
/ audit / outbox), including a failure injected after the intake
|
||||
credential write commits its statement — everything rolls back, no
|
||||
orphan credential.
|
||||
7. Wizard-facing zero-mutation witness (contract 3 §6.10 shape): no
|
||||
call → zero rows in `agents`/`agent_audit_events`/`agent_outbox`/
|
||||
`agent_idempotency_fence` attributable to the family.
|
||||
8. `is_system` injection attempt is rejected by DTO validation.
|
||||
9. Correlation-id witness (contract 5 §6.3): a correlation id submitted
|
||||
on `agent.enroll` appears in its audit event(s) and in the result;
|
||||
the same holds for `agent.enrollment.get`'s result; the §6.3 static
|
||||
companions (no `any`-typed boundary pass-through; single audit
|
||||
emitter) apply. §6.3's no-existence-oracle probe: an unauthorized
|
||||
`agent.enrollment.get` of an existing agent and a get of a
|
||||
nonexistent id return indistinguishable results.
|
||||
10. CLI-parity witness (contract 5 §6.4): a CLI smoke invocation of
|
||||
`agent.enroll` and `agent.enrollment.get` against the Gateway
|
||||
succeeds with the same typed results the web client receives. The
|
||||
implementation slice therefore SHIPS CLI exposure for both
|
||||
operations (contract 5 §4.5 — a Gateway command without CLI
|
||||
exposure is a tracked conformance gap; this design refuses to open
|
||||
one).
|
||||
11. Fail-closed witness (contract 5 §6.5): with the owning tool or
|
||||
grant state unreachable (fault injection), the operation returns
|
||||
the internal-fault or authorization-refusal class and performs no
|
||||
fallback read/write.
|
||||
|
||||
## 6. Out of scope
|
||||
|
||||
Wizard orchestration (M4-6); any UI (D8/D12); un-enroll/update lifecycle
|
||||
(no contract requires it in v1 — the legacy write surfaces named in §2
|
||||
keep serving existing consumers); OAuth login, multi-account, comms
|
||||
auto-enroll, model recommendation (PRD full flow, deferred by D11);
|
||||
contract amendments (F1 recorded above for a future S2 pass). CLI
|
||||
exposure is explicitly IN scope (witness 10 — contract 5 §4.5 binds it).
|
||||
@@ -13,6 +13,10 @@ status: active
|
||||
- [Documentation structure README implementation](2026-08-10-docs-structure-readme.md) — completed implementation plan for the documentation contract and atlas.
|
||||
- [Documentation catalog and truth audit](2026-08-10-docs-catalog-audit.md) — audit method, evidence statuses, deliverables, and acceptance criteria.
|
||||
|
||||
## Feature design plans
|
||||
|
||||
- [Agent enrollment command design](2026-08-29-agent-enrollment-command-design.md) — v1 rank-4 enrollment command family: contract composition, command surface, schema delta, witnesses (M4-4-0).
|
||||
|
||||
After a plan is delivered, update the canonical guide, contract, decision, or index. Do not cite a plan as proof that intended behavior shipped.
|
||||
|
||||
## Related
|
||||
|
||||
@@ -514,3 +514,47 @@ itself permits, so that contract does not stretch A1 by interpretation.
|
||||
default-open disclosure.
|
||||
5. Every other constraint of A1 — §8.1.2's remaining bullets, §8.2 in full,
|
||||
and §8.3's other acceptance criteria — is untouched.
|
||||
|
||||
## 10. Amendment A3 — capability-holder existence disclosure
|
||||
|
||||
**Status:** amendment to Amendment A2, added by reviewed PR together with
|
||||
contract 2 Amendment 1 (`rbac-grant-model.md` §8, this PR), under that
|
||||
amendment's ruling request (decision owner Jason). It binds if and only if
|
||||
contract 2 Amendment 1 ratifies; until then §9.1.2's sole-disclosure rule
|
||||
stands unmodified — which is consistent, because until ratification the
|
||||
company-CRUD capability class is empty and the carve-out below has no
|
||||
holders. Everything in §§1–9 remains binding verbatim, with exactly the one
|
||||
express modification below. The detailed contract text lives in
|
||||
`rbac-grant-model.md` §8.1; this amendment changes only what A2 itself
|
||||
permits, so that contract does not stretch A2 by interpretation.
|
||||
|
||||
### 10.1 What A3 modifies in A2
|
||||
|
||||
1. **Capability-holder disclosure (narrows §9.1.2's sole-disclosure rule
|
||||
by one carve-out).** §9.1.2 makes the directory the sole permitted
|
||||
existence disclosure and keeps private companies undisclosed to
|
||||
non-granted subjects everywhere. A3 admits exactly one further
|
||||
disclosure channel: a subject holding the company-CRUD capability
|
||||
(contract 2 §8), when exercising the hierarchy schema §5.5 visibility
|
||||
command, learns the target company's existence and its old/new
|
||||
visibility values through the command's redacted actor receipt —
|
||||
success for an existing target (private or directory alike) versus
|
||||
`not_found` for a nonexistent id — bounded exactly as contract 2 §8.1
|
||||
states: no name, slug, structure, content, grant, or membership
|
||||
information, and no read command of any kind. To every other
|
||||
non-granted subject, private companies remain undisclosed everywhere,
|
||||
including the directory; the directory remains the sole
|
||||
existence-disclosure _listing_.
|
||||
|
||||
### 10.2 What A3 explicitly does not change
|
||||
|
||||
1. The directory itself is unchanged: read-only, directory-class companies
|
||||
only, existence/name/slug only (§9.1.2's enumeration is narrowed for
|
||||
capability holders' receipts, widened for nothing).
|
||||
2. No join-request surface, no curation listing, no read command of any
|
||||
family is authorized (§9.2.2 unchanged; a curation listing is a further
|
||||
amendment per contract 2 §8.1).
|
||||
3. The canonical audit event for visibility mutations is untouched — it
|
||||
keeps hierarchy schema §5.2's full immutable target snapshot; the
|
||||
capability confers no audit read (contract 2 §8.5).
|
||||
4. Every other constraint of A1 and A2 is untouched.
|
||||
|
||||
@@ -24,6 +24,26 @@ fail-closed-fault, and existence-oracle observables added (§7);
|
||||
role-string namespacing rule added (§4.5); ruling request now names the
|
||||
interpretive resolution of PRD "admins".
|
||||
|
||||
Amendment 1 (company-CRUD capability): defines the capability class that
|
||||
contract 1 Amendment 1 (Ruling 4b, 2026-08-28) and hierarchy schema §5.5
|
||||
anticipate. §8 defines the capability as a platform-scoped, admin-assigned,
|
||||
audited delegation of exactly the hierarchy schema §5.5 company visibility
|
||||
command — no read command, no other company operation; the mutation's
|
||||
inherent existence disclosure is ratified as a bounded carve-out to
|
||||
hierarchy schema §6.7/§2.8 and to kanban SOT Amendment A2's
|
||||
sole-disclosure rule — SOT Amendment A3 (native-kanban-sot.md §10, this
|
||||
PR) expressly extends A2 by exactly this carve-out (§8.1). The holder
|
||||
sees only a redacted actor receipt; the canonical audit event keeps
|
||||
hierarchy schema §5.2's full immutable snapshot. The hierarchy role
|
||||
vocabulary (§2), every evaluation rule (§3), and grant management (§4) are
|
||||
untouched: the capability is not a `hierarchy_grants.role` value and
|
||||
evaluates outside the chain; capability-row deletion joins §6.1's
|
||||
revocation enumeration (§8.4). Until this amendment ratifies, the capability
|
||||
class is empty and
|
||||
the visibility command remains admin-only (hierarchy schema §5.5 states
|
||||
this fallback; the shipped gate at
|
||||
`apps/gateway/src/hierarchy/hierarchy.repository.ts` implements it).
|
||||
|
||||
Scope: the roles that can appear in `hierarchy_grants.role`, what a grant at
|
||||
each hierarchy level confers, how grants evaluate down the chain, how
|
||||
revocation propagates, and who may manage grants. Out of scope: the hierarchy
|
||||
@@ -242,6 +262,191 @@ contract 1 §6):
|
||||
8. Transfer: both-sides `owner` accepted, each single-side case refused
|
||||
(completing contract 1 §6.5).
|
||||
|
||||
## 8. Company-CRUD capability (Amendment 1)
|
||||
|
||||
Hierarchy schema §5.5 authorizes the company visibility mutation for
|
||||
exactly two actor classes: platform admins and "subjects holding the
|
||||
company-CRUD capability that a follow-up amendment to contract 2 will
|
||||
define". This section is that definition. The name is historical — coined
|
||||
in contract 1 Amendment 1 before the capability's content was fixed — and
|
||||
confers nothing by connotation: the ratified content is exactly §8.1.
|
||||
Company _creation_ is already ruled open to active users and always
|
||||
private (contract 3 §5.2, Ruling 4); rename, delete, and transfer of
|
||||
companies remain hierarchy `owner` operations (§2.3, §5); none of those is
|
||||
part of this capability, and widening it to any other operation is a
|
||||
further amendment, not an implementation decision.
|
||||
|
||||
1. **Content: exactly one command, no read command, disclosure stated.**
|
||||
Holding the capability authorizes executing the hierarchy schema §5.5
|
||||
visibility command (`companies.visibility`, both directions:
|
||||
`private → directory` and `directory → private`) on any company in the
|
||||
deployment, and no other command of any family. It confers **no read
|
||||
command**: no company enumeration, no curation listing, no structure
|
||||
read. The practical flow this implies is deliberate: to publish a
|
||||
private company, the holder is given the target identifier by the
|
||||
requesting company `owner` out of band; to unpublish, the target is
|
||||
already directory-listed. A curation listing for capability holders,
|
||||
if ever wanted, is a further amendment with its own disclosure
|
||||
analysis under hierarchy schema §6.7.
|
||||
|
||||
**Existence disclosure carve-out, stated rather than pretended away:**
|
||||
exercising a mutation inherently discloses its target's existence.
|
||||
The command's result distinguishes an existing company (success, for
|
||||
private and directory targets alike) from a nonexistent id
|
||||
(`not_found`), so a holder presenting candidate ids learns existence —
|
||||
exactly as a platform admin already does through the same command.
|
||||
This amendment ratifies that disclosure as part of the §5.5 curation
|
||||
authority, bounded as follows. The holder-visible surface is the
|
||||
command's **actor receipt** — the mutation result payload, carrying
|
||||
exactly the target id, old visibility, and new visibility, and
|
||||
**nothing else**: no name, slug, structure, content, grant, or
|
||||
membership information. The actor receipt is a redacted projection
|
||||
distinct from the **canonical audit event**, which is unchanged by
|
||||
this amendment: it keeps hierarchy schema §5.2's deletion-safe
|
||||
immutable target snapshot (id, slug, and parent chain at event time)
|
||||
in full. The two never converge on the holder: the capability confers
|
||||
no audit read (§8.5), so the canonical event — and with it the slug
|
||||
and parent chain — is reachable only by subjects independently
|
||||
authorized to read audit data, never through this capability. A
|
||||
successful publish additionally makes the target directory-listed to
|
||||
every authenticated user; that is the command's ratified purpose
|
||||
(hierarchy schema §5.5), not a leak. Hierarchy schema §6.7's
|
||||
existence-oracle rule and §2.8's directory-only disclosure are amended
|
||||
by exactly this carve-out for capability holders, kanban SOT Amendment
|
||||
A3 (native-kanban-sot.md §10, this PR) expressly extends A2's
|
||||
sole-disclosure enumeration by the same carve-out, and all three are
|
||||
otherwise untouched. Witnessed in §8.6.3.
|
||||
|
||||
2. **Holding: platform-scoped assignment, user subjects only.** The
|
||||
capability is not a hierarchy grant: it attaches to no node, has no
|
||||
role, and never enters §3 chain evaluation. It is held via a
|
||||
`platform_capabilities` table whose column set is exactly (nothing
|
||||
else, per the contract 1 §2.7 exhaustiveness discipline):
|
||||
- `id` — uuid, primary key;
|
||||
- `user_id` — text, NOT NULL, FK `users` **ON DELETE RESTRICT**;
|
||||
- `capability` — text, NOT NULL, constraint-checked against exactly
|
||||
`company_crud`;
|
||||
- `granted_by` — text, NOT NULL, FK `users` **ON DELETE RESTRICT**;
|
||||
- `created_at` — timestamptz, NOT NULL;
|
||||
- UNIQUE (`user_id`, `capability`).
|
||||
|
||||
The user FKs are **text**, not uuid, because `users.id` is a BetterAuth
|
||||
text key (`packages/db/src/schema.ts`; custody schema records the same)
|
||||
— PostgreSQL cannot reference a text primary key with a uuid column.
|
||||
This matches the shipped `hierarchy_grants` shape exactly: uuid
|
||||
surrogate `id`, text FKs to `users`.
|
||||
|
||||
Both user FKs are RESTRICT for the same reason contract 1 §3.3 pins
|
||||
RESTRICT on principal FKs: the identity contract (§7.3) gates user
|
||||
deletion, and a cascade here could silently destroy a capability
|
||||
without its §8.3 revocation audit event. Revocation is row deletion
|
||||
through the §8.3 command — there is no other removal path, no expiry
|
||||
column, and no tombstone. A deactivated holder confers nothing while
|
||||
deactivated: identity contract §7.1 denies all authorization to
|
||||
deactivated accounts, and the §8.4 predicate evaluates on the
|
||||
authenticated live user. No team subjects (§1.4's suspension reasoning
|
||||
applies with more force here — a workspace-bound team holding
|
||||
deployment-wide curation authority has no ratified meaning).
|
||||
|
||||
3. **Assignment is instance administration on the normal admin surface.**
|
||||
Only platform admins (`users.role = 'admin'`) may assign or revoke the
|
||||
capability, through an ordinary admin command (the same command class
|
||||
`AdminGuard` governs, §1.1) — not through direct table writes.
|
||||
Assignment delegates a slice of instance administration and is itself
|
||||
an instance-administration act under §1.1. A capability holder as such
|
||||
may NOT assign or revoke it (no self-propagation). Every assignment
|
||||
and revocation is a semantic audit event carrying actor, verb, subject
|
||||
user, and capability; serialized capability strings are namespaced per
|
||||
§4.5 (`platform-capability:company-crud` — a bare `company_crud` in
|
||||
any serialized artifact is non-conformant).
|
||||
4. **Evaluation and revocation follow this contract's existing rules.**
|
||||
The hierarchy schema §5.5 command's authorization predicate is:
|
||||
`users.role = 'admin'` OR a live `platform_capabilities` row
|
||||
(`user_id`, `company_crud`). Both disjuncts are evaluated live and
|
||||
fail closed per §3.5 — **independently**: with capability state
|
||||
unreadable (fault), the capability disjunct denies, but a platform
|
||||
admin whose `users.role` is readable remains authorized through the
|
||||
admin disjunct; with role state unreadable, the admin disjunct denies
|
||||
likewise. A decision that can read neither denies. Capability-row
|
||||
deletion is hereby added to §6.1's enumerated revocation paths:
|
||||
it propagates identically, under §6.2's bound, on every transport —
|
||||
no new HTTP/MCP command authorized by the deleted row after the
|
||||
revoking transaction commits, and any cached authorization is
|
||||
invalidated in the revoking transaction (§3.5).
|
||||
5. **What it does not confer**, stated so implementing PRs cannot drift:
|
||||
no hierarchy grant or effective role at any node; no workspace
|
||||
authorization or membership; no content, structure, or roll-up read;
|
||||
no grant management (§4.1 unchanged); no MCP scope; no other instance
|
||||
administration (user management, system settings, provider
|
||||
configuration remain platform-admin-only); no company create, rename,
|
||||
delete, or transfer. Hierarchy schema §5.5's rule that a company
|
||||
`owner` as such may NOT change visibility is unchanged — `owner` and
|
||||
this capability are disjoint authorities that combine only by a
|
||||
subject holding both.
|
||||
6. **Verification requirements** (extends §7, binding on implementing
|
||||
PRs):
|
||||
1. Schema witnesses (real PostgreSQL, `ci-postgres` service in the
|
||||
`test` CI step): the `capability` CHECK constraint rejects any
|
||||
value outside `company_crud`; NOT NULL enforced on every declared
|
||||
NOT NULL column; UNIQUE (`user_id`, `capability`) rejects a
|
||||
duplicate; both user FKs reject a dangling reference AND deleting a
|
||||
referenced user is refused (RESTRICT witnessed in both directions);
|
||||
the table's column set is exactly the §8.2 declared set (contract 1
|
||||
§6.2 discipline).
|
||||
2. Capability-only command matrix — the witness that proves "exactly
|
||||
one command", not merely "at least one": a non-admin holder with no
|
||||
other grants succeeds on the visibility command in **both**
|
||||
directions with contract 1 §5.2's audit event (old and new values
|
||||
as semantic content), and the **same** actor is refused, case by
|
||||
enumerated case: every hierarchy mutation family (company/child
|
||||
create under another's node, rename, delete, transfer); grant
|
||||
create/change/revoke; the workspace read and write command
|
||||
families; roll-up reads; structure reads — including the
|
||||
not-found-indistinguishable refusal on a structure read of the very
|
||||
company they just mutated (hierarchy schema §6.7); every
|
||||
instance-administration surface other than the visibility command
|
||||
(user management, system settings, provider configuration, and
|
||||
capability assign/revoke itself); and MCP scope derivation yields
|
||||
nothing — the §7.4 deny-by-default matrix gains this row. Company
|
||||
creation compares against an eligible-user baseline: the holder's
|
||||
create behaves exactly as any active user's — always `private`,
|
||||
and a creation request carrying a visibility argument is refused
|
||||
for holder and baseline alike (contract 3 §5.2).
|
||||
3. Disclosure bound (§8.1 carve-out witnessed, receipt and canonical
|
||||
event separately): the mutation result for a private-valid target,
|
||||
a directory-valid target, and a nonexistent id is exactly {success,
|
||||
success, `not_found`}; the actor receipt for a success carries
|
||||
exactly {target id, old visibility, new visibility} and no result
|
||||
or error payload carries name, slug, structure, content, grant, or
|
||||
membership data; the canonical audit event for the same mutation —
|
||||
asserted directly against the hierarchy outbox, not through any
|
||||
holder-facing surface — carries hierarchy schema §5.2's full
|
||||
immutable snapshot (id, slug, parent chain); and the holder's
|
||||
attempt to read audit data is refused (no audit read conferred,
|
||||
§8.5), proving the receipt/event separation reaches the holder as
|
||||
a redaction, not a weakened event.
|
||||
4. Assignment path, both polarities: a platform admin assigns and
|
||||
revokes through the normal admin command (positive witnesses —
|
||||
assign then observe the §8.6.2 allow, revoke then observe deny); a
|
||||
non-admin — including a current capability holder — is refused
|
||||
assign and revoke; every assign/revoke produces its audit event
|
||||
with the namespaced string (§8.3); a direct-write path that skips
|
||||
the command surface is non-conformant (the §8.3 command is the only
|
||||
writer of `platform_capabilities`).
|
||||
5. Revocation joins the §7.6 matrix: assignment is decision-time-live
|
||||
(capability assigned → the holder's next visibility command allows,
|
||||
no re-login); after row deletion, the ex-holder's next visibility
|
||||
command is refused **on every exposed transport**, measured with
|
||||
the revocation and the decision on distinct physical connections; a
|
||||
cached-authorization implementation proves transactional
|
||||
invalidation (§3.5). Fail-closed fault witnesses, both disjuncts
|
||||
(§8.4): with `platform_capabilities` unreadable, a non-admin holder
|
||||
is denied while a platform admin remains authorized; with role
|
||||
state unreadable, the admin disjunct denies.
|
||||
6. Owner-as-such refusal re-witnessed: hierarchy schema §6.9's
|
||||
owner-cannot-publish witness re-asserted with the
|
||||
`platform_capabilities` table present and empty for that owner.
|
||||
|
||||
## Ruling request
|
||||
|
||||
Ratify sections 1–7 as written, with one decision embedded and one
|
||||
@@ -257,3 +462,17 @@ interpretive resolution named:
|
||||
platform admins. A1 §8.1.3 does not attribute grant declaration to
|
||||
platform admins, and the §1.1 decision above is what makes this reading
|
||||
binding.
|
||||
|
||||
## Ruling request (Amendment 1)
|
||||
|
||||
Ratify §8, the Amendment 1 header note, and kanban SOT Amendment A3
|
||||
(native-kanban-sot.md §10 — the express A2 carve-out extension, which
|
||||
binds only with this ratification) as written, with one decision
|
||||
embedded:
|
||||
|
||||
- Decision: the company-CRUD capability is a platform-scoped,
|
||||
admin-assigned, audited delegation of exactly the hierarchy schema §5.5
|
||||
visibility command — no read command, no other company operation, with
|
||||
the mutation's inherent existence disclosure ratified as a bounded
|
||||
carve-out (§8.1). Say "agreed" or name the additional operations (or
|
||||
the curation listing) you want it to carry.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createMissionTasksRepo } from './mission-tasks.js';
|
||||
|
||||
/**
|
||||
* SHARED-CONTRACT §5.5 "mission_tasks.status write prohibition": this repo is
|
||||
* the sole path that authors mission_tasks.status from caller input (storage
|
||||
* tier migration is row transport and preserves stored values; the generic
|
||||
* storage adapters have no mission_tasks caller), and it must never forward a
|
||||
* caller-supplied status to the database on create or update. Callers keep
|
||||
* working (the field is accepted and ignored), so these tests assert on what
|
||||
* reaches the Drizzle chain, not on rejection.
|
||||
*/
|
||||
|
||||
function makeInsertDb(returned: unknown[]) {
|
||||
const values = vi.fn((_v: unknown) => ({ returning: vi.fn().mockResolvedValue(returned) }));
|
||||
return { db: { insert: vi.fn(() => ({ values })) }, values };
|
||||
}
|
||||
|
||||
function makeUpdateDb(returned: unknown[]) {
|
||||
const set = vi.fn((_v: unknown) => ({
|
||||
where: vi.fn(() => ({ returning: vi.fn().mockResolvedValue(returned) })),
|
||||
}));
|
||||
return { db: { update: vi.fn(() => ({ set })) }, set };
|
||||
}
|
||||
|
||||
describe('createMissionTasksRepo — status write prohibition', () => {
|
||||
it('create strips a caller-supplied status before insert', async () => {
|
||||
const { db, values } = makeInsertDb([{ id: 'mt1', status: 'not-started' }]);
|
||||
const repo = createMissionTasksRepo(db as never);
|
||||
|
||||
const result = await repo.create({
|
||||
missionId: 'm1',
|
||||
userId: 'u1',
|
||||
status: 'done',
|
||||
description: 'd',
|
||||
} as never);
|
||||
|
||||
expect(values).toHaveBeenCalledTimes(1);
|
||||
const inserted = values.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect('status' in inserted).toBe(false);
|
||||
expect(inserted.missionId).toBe('m1');
|
||||
expect(inserted.description).toBe('d');
|
||||
expect(result.id).toBe('mt1');
|
||||
});
|
||||
|
||||
it('create without status still inserts (DB default applies)', async () => {
|
||||
const { db, values } = makeInsertDb([{ id: 'mt2' }]);
|
||||
const repo = createMissionTasksRepo(db as never);
|
||||
|
||||
await repo.create({ missionId: 'm1', userId: 'u1' } as never);
|
||||
|
||||
const inserted = values.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect('status' in inserted).toBe(false);
|
||||
});
|
||||
|
||||
it('update strips a caller-supplied status but keeps the other fields', async () => {
|
||||
const { db, set } = makeUpdateDb([{ id: 'mt1', notes: 'n' }]);
|
||||
const repo = createMissionTasksRepo(db as never);
|
||||
|
||||
const result = await repo.update('mt1', { status: 'done', notes: 'n' } as never);
|
||||
|
||||
expect(set).toHaveBeenCalledTimes(1);
|
||||
const updated = set.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect('status' in updated).toBe(false);
|
||||
expect(updated.notes).toBe('n');
|
||||
expect(updated.updatedAt).toBeInstanceOf(Date);
|
||||
expect(result?.id).toBe('mt1');
|
||||
});
|
||||
|
||||
it('update with only status degenerates to a timestamp-only update', async () => {
|
||||
const { db, set } = makeUpdateDb([{ id: 'mt1' }]);
|
||||
const repo = createMissionTasksRepo(db as never);
|
||||
|
||||
await repo.update('mt1', { status: 'blocked' } as never);
|
||||
|
||||
const updated = set.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(Object.keys(updated)).toEqual(['updatedAt']);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,24 @@ import { eq, and, type Db, missionTasks } from '@mosaicstack/db';
|
||||
export type MissionTask = typeof missionTasks.$inferSelect;
|
||||
export type NewMissionTask = typeof missionTasks.$inferInsert;
|
||||
|
||||
// SHARED-CONTRACT §5.1 phase 1 / §5.4: mission_tasks.status is prohibited as a
|
||||
// write source through the N-1 window. This repo is the sole path that authors
|
||||
// status from caller input, so the field is stripped here — accepted and
|
||||
// ignored rather than rejected, because the legacy surface is frozen with
|
||||
// existing consumers kept working (tool-gateway-mapping.md §3.2). Two other
|
||||
// surfaces touch the column and are deliberately NOT stripped:
|
||||
// packages/storage/migrate-tier.ts copies whole rows between storage tiers and
|
||||
// must preserve the stored value verbatim, and the generic table-keyed storage
|
||||
// adapters register mission_tasks but have no caller that targets it (runtime
|
||||
// callers use fixed collection constants). Neither authors a new status. The
|
||||
// column keeps its DB default, stays declared and readable, and is retired
|
||||
// only after no readers remain.
|
||||
function stripStatus<T extends { status?: unknown }>(data: T): Omit<T, 'status'> {
|
||||
const rest = { ...data };
|
||||
delete rest.status;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export function createMissionTasksRepo(db: Db) {
|
||||
return {
|
||||
async findByMission(missionId: string): Promise<MissionTask[]> {
|
||||
@@ -30,14 +48,14 @@ export function createMissionTasksRepo(db: Db) {
|
||||
},
|
||||
|
||||
async create(data: NewMissionTask): Promise<MissionTask> {
|
||||
const rows = await db.insert(missionTasks).values(data).returning();
|
||||
const rows = await db.insert(missionTasks).values(stripStatus(data)).returning();
|
||||
return rows[0]!;
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<NewMissionTask>): Promise<MissionTask | undefined> {
|
||||
const rows = await db
|
||||
.update(missionTasks)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.set({ ...stripStatus(data), updatedAt: new Date() })
|
||||
.where(eq(missionTasks.id, id))
|
||||
.returning();
|
||||
return rows[0];
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
CREATE TYPE "public"."agent_outbox_status" AS ENUM('pending', 'processing', 'delivered');--> statement-breakpoint
|
||||
CREATE TABLE "agent_audit_events" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"seq" bigint GENERATED ALWAYS AS IDENTITY (sequence name "agent_audit_events_seq_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||
"event_type" text NOT NULL,
|
||||
"actor_id" text NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"correlation_id" text NOT NULL,
|
||||
"causation_id" uuid,
|
||||
"payload" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "agent_audit_events_type_check" CHECK (event_type IN ('agent.enrolled', 'agent.enrollment.replayed'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "agent_idempotency_fence" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"operation" text NOT NULL,
|
||||
"idempotency_key" text NOT NULL,
|
||||
"actor_id" text NOT NULL,
|
||||
"authorization_scope" text NOT NULL,
|
||||
"payload_digest" text NOT NULL,
|
||||
"replay_mode" text DEFAULT 'actor-bound' NOT NULL,
|
||||
"outcome_agent_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "agent_idempotency_fence_replay_mode_check" CHECK (replay_mode IN ('actor-bound', 'shared'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "agent_outbox" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"event_id" uuid NOT NULL,
|
||||
"correlation_id" text NOT NULL,
|
||||
"status" "agent_outbox_status" DEFAULT 'pending' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"delivered_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "agents" ADD COLUMN "harness" text;--> statement-breakpoint
|
||||
ALTER TABLE "agents" ADD COLUMN "enrolled_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "agent_audit_events" ADD CONSTRAINT "agent_audit_events_causation_id_agent_audit_events_id_fk" FOREIGN KEY ("causation_id") REFERENCES "public"."agent_audit_events"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "agent_outbox" ADD CONSTRAINT "agent_outbox_event_id_agent_audit_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."agent_audit_events"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "agent_audit_events_seq_idx" ON "agent_audit_events" USING btree ("seq");--> statement-breakpoint
|
||||
CREATE INDEX "agent_audit_events_agent_seq_idx" ON "agent_audit_events" USING btree ("agent_id","seq");--> statement-breakpoint
|
||||
CREATE INDEX "agent_audit_events_correlation_idx" ON "agent_audit_events" USING btree ("correlation_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "agent_idempotency_fence_operation_key_idx" ON "agent_idempotency_fence" USING btree ("operation","idempotency_key");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "agent_outbox_event_idx" ON "agent_outbox" USING btree ("event_id");--> statement-breakpoint
|
||||
CREATE INDEX "agent_outbox_status_created_idx" ON "agent_outbox" USING btree ("status","created_at");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -148,6 +148,13 @@
|
||||
"when": 1787963521142,
|
||||
"tag": "0020_special_betty_brant",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1788053011351,
|
||||
"tag": "0021_agent_enrollment",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* Agent enrollment schema witnesses — M4-4a, the schema-level half of the
|
||||
* witness list in docs/plans/2026-08-29-agent-enrollment-command-design.md §5.
|
||||
*
|
||||
* Witnesses the guarantees migration 0021's tables themselves carry: the
|
||||
* event-type CHECK, monotonic per-agent append order (`seq`), deletion-safe
|
||||
* linkage (no foreign key from the events or fence tables into `agents` —
|
||||
* rows survive a legacy CRUD DELETE of the agent), the causation self-FK,
|
||||
* the outbox's FK/uniqueness/status shape, the fence's UNIQUE
|
||||
* (operation, key) and replay-mode CHECK, and the nullable enrollment
|
||||
* columns on `agents` (legacy rows insert without them). The command-level
|
||||
* witnesses (never-echo, same-tx atomicity, replay semantics, correlation,
|
||||
* CLI parity, fail-closed) belong to the M4-4b implementation slice.
|
||||
*
|
||||
* Two legs run the same witness body:
|
||||
* - PGlite (WASM Postgres): always runs.
|
||||
* - Real PostgreSQL: runs when DATABASE_URL is set — the binding witness;
|
||||
* CI migrates ci-postgres before `pnpm test`.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createDb } from './client.js';
|
||||
import { createPgliteDb } from './client-pglite.js';
|
||||
import { runPgliteMigrations } from './migrate.js';
|
||||
import { agentAuditEvents, agentIdempotencyFence, agentOutbox, agents } from './schema.js';
|
||||
|
||||
type AnyDb = {
|
||||
db: {
|
||||
insert: (t: unknown) => { values: (v: unknown) => Promise<unknown> };
|
||||
execute: (q: unknown) => Promise<{ rows?: unknown[] } | unknown[]>;
|
||||
};
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** Match a constraint failure anywhere along drizzle's cause chain. */
|
||||
async function expectViolation(p: Promise<unknown>, re: RegExp, label = ''): Promise<void> {
|
||||
let err: unknown;
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err, label || 'expected the statement to be refused').toBeDefined();
|
||||
const messages: string[] = [];
|
||||
let cur: unknown = err;
|
||||
while (cur instanceof Error) {
|
||||
messages.push(cur.message);
|
||||
cur = (cur as { cause?: unknown }).cause;
|
||||
}
|
||||
expect(messages.join(' | '), label).toMatch(re);
|
||||
}
|
||||
|
||||
function rows(res: { rows?: unknown[] } | unknown[]): Record<string, unknown>[] {
|
||||
return (Array.isArray(res) ? res : (res.rows ?? [])) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/** Unique per-run prefix so real-PG runs never collide and clean up safely. */
|
||||
const T = `agent-e-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
type EventInsert = typeof agentAuditEvents.$inferInsert;
|
||||
|
||||
function eventRow(overrides: Partial<EventInsert> = {}): EventInsert {
|
||||
return {
|
||||
eventType: 'agent.enrolled',
|
||||
actorId: `${T}-actor`,
|
||||
agentId: randomUUID(),
|
||||
correlationId: `${T}-corr-${randomUUID()}`,
|
||||
payload: {
|
||||
harness: 'claude-code',
|
||||
provider: 'anthropic',
|
||||
name: 'x',
|
||||
credentialMode: 'reference',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type FenceInsert = typeof agentIdempotencyFence.$inferInsert;
|
||||
|
||||
function fenceRow(overrides: Partial<FenceInsert> = {}): FenceInsert {
|
||||
return {
|
||||
operation: 'agent.enroll',
|
||||
idempotencyKey: `${T}-${randomUUID()}`,
|
||||
actorId: `${T}-actor`,
|
||||
authorizationScope: 'platform-user',
|
||||
payloadDigest: `${T}-digest`,
|
||||
outcomeAgentId: randomUUID(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function witnessSuite(getHandle: () => AnyDb): void {
|
||||
const db = () => getHandle().db as unknown as ReturnType<typeof createDb>['db'];
|
||||
|
||||
afterAll(async () => {
|
||||
const d = db();
|
||||
await d.execute(sql`DELETE FROM agent_outbox WHERE correlation_id LIKE ${T + '%'}`);
|
||||
// Caused events first: the causation self-FK is RESTRICT.
|
||||
await d.execute(
|
||||
sql`DELETE FROM agent_audit_events WHERE correlation_id LIKE ${T + '%'} AND causation_id IS NOT NULL`,
|
||||
);
|
||||
await d.execute(sql`DELETE FROM agent_audit_events WHERE correlation_id LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM agent_idempotency_fence WHERE actor_id LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM agents WHERE name LIKE ${T + '%'}`);
|
||||
});
|
||||
|
||||
// ── agents: nullable enrollment columns (no backfill semantics) ────────────
|
||||
|
||||
it('legacy agent rows insert without enrollment columns; enrolled rows carry both', async () => {
|
||||
const legacyId = randomUUID();
|
||||
await db()
|
||||
.insert(agents)
|
||||
.values({
|
||||
id: legacyId,
|
||||
name: `${T}-legacy`,
|
||||
provider: 'anthropic',
|
||||
model: 'claude-fable-5',
|
||||
});
|
||||
const legacy = rows(
|
||||
await db().execute(sql`SELECT harness, enrolled_at FROM agents WHERE id = ${legacyId}`),
|
||||
)[0]!;
|
||||
expect(legacy['harness']).toBeNull();
|
||||
expect(legacy['enrolled_at']).toBeNull();
|
||||
|
||||
const enrolledId = randomUUID();
|
||||
await db()
|
||||
.insert(agents)
|
||||
.values({
|
||||
id: enrolledId,
|
||||
name: `${T}-enrolled`,
|
||||
provider: 'anthropic',
|
||||
model: 'claude-fable-5',
|
||||
harness: 'claude-code',
|
||||
enrolledAt: new Date(),
|
||||
});
|
||||
const enrolled = rows(
|
||||
await db().execute(sql`SELECT harness, enrolled_at FROM agents WHERE id = ${enrolledId}`),
|
||||
)[0]!;
|
||||
expect(enrolled['harness']).toBe('claude-code');
|
||||
expect(enrolled['enrolled_at']).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── agent_audit_events: CHECK, ordering, deletion-safe linkage ─────────────
|
||||
|
||||
it('accepts both declared event types and refuses an undeclared one', async () => {
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ eventType: 'agent.enrolled' }));
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ eventType: 'agent.enrollment.replayed' }));
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ eventType: 'agent.deleted' })),
|
||||
/type_check|violates check/i,
|
||||
'undeclared event type must be refused',
|
||||
);
|
||||
});
|
||||
|
||||
it('assigns strictly increasing seq in insert order for one agent', async () => {
|
||||
const agentId = randomUUID();
|
||||
const c1 = `${T}-seq-1-${randomUUID()}`;
|
||||
const c2 = `${T}-seq-2-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ agentId, correlationId: c1 }));
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ agentId, eventType: 'agent.enrollment.replayed', correlationId: c2 }));
|
||||
const res = rows(
|
||||
await db().execute(
|
||||
sql`SELECT correlation_id, seq FROM agent_audit_events WHERE agent_id = ${agentId} ORDER BY seq ASC`,
|
||||
),
|
||||
);
|
||||
expect(res.map((r) => r['correlation_id'])).toEqual([c1, c2]);
|
||||
expect(Number(res[1]!['seq'])).toBeGreaterThan(Number(res[0]!['seq']));
|
||||
});
|
||||
|
||||
it('has no foreign key into agents, and events survive agent deletion', async () => {
|
||||
const fks = rows(
|
||||
await db().execute(sql`
|
||||
SELECT ccu.table_name AS referenced_table
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = 'agent_audit_events'
|
||||
`),
|
||||
);
|
||||
// The causation self-FK is the ONLY foreign key on the events table.
|
||||
expect([...new Set(fks.map((r) => r['referenced_table']))]).toEqual(['agent_audit_events']);
|
||||
|
||||
const agentId = randomUUID();
|
||||
await db()
|
||||
.insert(agents)
|
||||
.values({ id: agentId, name: `${T}-doomed`, provider: 'anthropic', model: 'claude-fable-5' });
|
||||
const corr = `${T}-survive-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ agentId, correlationId: corr }));
|
||||
await db().execute(sql`DELETE FROM agents WHERE id = ${agentId}`);
|
||||
const after = rows(
|
||||
await db().execute(
|
||||
sql`SELECT agent_id FROM agent_audit_events WHERE correlation_id = ${corr}`,
|
||||
),
|
||||
);
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0]!['agent_id']).toBe(agentId);
|
||||
});
|
||||
|
||||
it('enforces the causation self-FK and RESTRICTs deleting a cause', async () => {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ causationId: randomUUID() })),
|
||||
/foreign key/i,
|
||||
'causation must reference an existing event',
|
||||
);
|
||||
const causeCorr = `${T}-cause-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ correlationId: causeCorr }));
|
||||
const cause = rows(
|
||||
await db().execute(
|
||||
sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${causeCorr}`,
|
||||
),
|
||||
)[0]!;
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(
|
||||
eventRow({
|
||||
eventType: 'agent.enrollment.replayed',
|
||||
causationId: cause['id'] as string,
|
||||
}),
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM agent_audit_events WHERE id = ${cause['id'] as string}`),
|
||||
/foreign key/i,
|
||||
'a cause with dependent events must not be deletable',
|
||||
);
|
||||
});
|
||||
|
||||
// ── agent_outbox shape ─────────────────────────────────────────────────────
|
||||
|
||||
it('outbox rows require an existing event, one outbox row per event, closed status enum', async () => {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(agentOutbox)
|
||||
.values({ eventId: randomUUID(), correlationId: `${T}-corr` }),
|
||||
/foreign key/i,
|
||||
'outbox must reference an existing event',
|
||||
);
|
||||
const corr = `${T}-ob-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ correlationId: corr }));
|
||||
const event = rows(
|
||||
await db().execute(sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${corr}`),
|
||||
)[0]!;
|
||||
const eventId = event['id'] as string;
|
||||
await db().insert(agentOutbox).values({ eventId, correlationId: corr });
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(agentOutbox)
|
||||
.values({ eventId, correlationId: `${T}-ob2` }),
|
||||
/duplicate key|unique/i,
|
||||
'one outbox record per event',
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO agent_outbox (event_id, correlation_id, status)
|
||||
VALUES (${eventId}, ${`${T}-ob3`}, 'failed')`,
|
||||
),
|
||||
/invalid input value for enum|22P02/i,
|
||||
'status outside pending/processing/delivered must be refused',
|
||||
);
|
||||
});
|
||||
|
||||
it('outbox FK RESTRICTs event deletion while the outbox row exists', async () => {
|
||||
const corr = `${T}-obr-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ correlationId: corr }));
|
||||
const event = rows(
|
||||
await db().execute(sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${corr}`),
|
||||
)[0]!;
|
||||
await db()
|
||||
.insert(agentOutbox)
|
||||
.values({ eventId: event['id'] as string, correlationId: corr });
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM agent_audit_events WHERE id = ${event['id'] as string}`),
|
||||
/foreign key/i,
|
||||
);
|
||||
});
|
||||
|
||||
// ── agent_idempotency_fence: (operation, key) uniqueness, mode CHECK ───────
|
||||
|
||||
it('refuses a duplicate (operation, key) pair but allows the same key under another operation', async () => {
|
||||
const key = `${T}-fence-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentIdempotencyFence)
|
||||
.values(fenceRow({ idempotencyKey: key }));
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(agentIdempotencyFence)
|
||||
.values(fenceRow({ idempotencyKey: key })),
|
||||
/duplicate key|unique/i,
|
||||
'fence uniqueness is (operation, key)',
|
||||
);
|
||||
// Same key, different operation identifier: a distinct fence.
|
||||
await db()
|
||||
.insert(agentIdempotencyFence)
|
||||
.values(fenceRow({ idempotencyKey: key, operation: 'agent.other' }));
|
||||
});
|
||||
|
||||
it('defaults replay mode to actor-bound and refuses an undeclared mode', async () => {
|
||||
const key = `${T}-mode-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentIdempotencyFence)
|
||||
.values(fenceRow({ idempotencyKey: key }));
|
||||
const row = rows(
|
||||
await db().execute(
|
||||
sql`SELECT replay_mode FROM agent_idempotency_fence WHERE idempotency_key = ${key}`,
|
||||
),
|
||||
)[0]!;
|
||||
expect(row['replay_mode']).toBe('actor-bound');
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(agentIdempotencyFence)
|
||||
.values(fenceRow({ replayMode: 'unbound' as 'actor-bound' })),
|
||||
/replay_mode_check|violates check/i,
|
||||
'a mode outside actor-bound/shared must be refused',
|
||||
);
|
||||
});
|
||||
|
||||
it('fence has no foreign key at all, and rows survive agent deletion', async () => {
|
||||
const fks = rows(
|
||||
await db().execute(sql`
|
||||
SELECT ccu.table_name AS referenced_table
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = 'agent_idempotency_fence'
|
||||
`),
|
||||
);
|
||||
expect(fks).toHaveLength(0);
|
||||
|
||||
const agentId = randomUUID();
|
||||
await db()
|
||||
.insert(agents)
|
||||
.values({
|
||||
id: agentId,
|
||||
name: `${T}-fdoomed`,
|
||||
provider: 'anthropic',
|
||||
model: 'claude-fable-5',
|
||||
});
|
||||
const key = `${T}-fsurvive-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentIdempotencyFence)
|
||||
.values(fenceRow({ idempotencyKey: key, outcomeAgentId: agentId }));
|
||||
await db().execute(sql`DELETE FROM agents WHERE id = ${agentId}`);
|
||||
const after = rows(
|
||||
await db().execute(
|
||||
sql`SELECT outcome_agent_id FROM agent_idempotency_fence WHERE idempotency_key = ${key}`,
|
||||
),
|
||||
);
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0]!['outcome_agent_id']).toBe(agentId);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Leg 1: PGlite (always runs — local witness signal) ───────────────────────
|
||||
|
||||
describe('agent enrollment schema witnesses — PGlite', () => {
|
||||
let dir: string;
|
||||
let handle: ReturnType<typeof createPgliteDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'agent-enroll-witness-'));
|
||||
handle = createPgliteDb(dir);
|
||||
await runPgliteMigrations(handle);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await handle.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
witnessSuite(() => handle as unknown as AnyDb);
|
||||
});
|
||||
|
||||
// ── Leg 2: real PostgreSQL (binding witness, ci-postgres in CI) ──────────────
|
||||
|
||||
const hasPostgres = Boolean(process.env['DATABASE_URL']);
|
||||
|
||||
describe.skipIf(!hasPostgres)('agent enrollment schema witnesses — real PostgreSQL', () => {
|
||||
let handle: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(() => {
|
||||
handle = createDb(process.env['DATABASE_URL']!);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
witnessSuite(() => handle as unknown as AnyDb);
|
||||
});
|
||||
@@ -302,6 +302,11 @@ export const agents = pgTable(
|
||||
skills: jsonb('skills').$type<string[]>(),
|
||||
isSystem: boolean('is_system').notNull().default(false),
|
||||
config: jsonb('config'),
|
||||
// Enrollment (M4-4, docs/plans/2026-08-29-agent-enrollment-command-design.md §4).
|
||||
// NULL on both marks a legacy (non-enrolled) row; no backfill — enrollment
|
||||
// is a fact the rank-4 command creates, not one to invent for existing rows.
|
||||
harness: text('harness'),
|
||||
enrolledAt: timestamp('enrolled_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
@@ -1279,3 +1284,109 @@ export const hierarchyOutbox = pgTable(
|
||||
index('hierarchy_outbox_status_created_idx').on(t.status, t.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent enrollment (M4-4) — rank-4 command family audit/outbox/fence stores.
|
||||
// Design: docs/plans/2026-08-29-agent-enrollment-command-design.md §4.
|
||||
// Pattern reuse from the hierarchy audit/outbox pair, separate store. Audit
|
||||
// rows reference the agent by snapshot id, deliberately with NO FK, so audit
|
||||
// history survives agent deletion through the legacy CRUD DELETE path.
|
||||
// Idempotency for this family lives in agent_idempotency_fence (contract 3
|
||||
// §4.3 envelope, ratified into contract 5 §4 via contract 3 §7 item 4) — the
|
||||
// audit and outbox tables carry no idempotency key of their own.
|
||||
|
||||
export const AGENT_AUDIT_EVENT_TYPES = [
|
||||
// Semantic mutation event of agent.enroll.
|
||||
'agent.enrolled',
|
||||
// Non-mutation access class: a passing idempotent replay appends this and
|
||||
// nothing else (accessing principal, current correlation id, fence-row
|
||||
// reference in the payload).
|
||||
'agent.enrollment.replayed',
|
||||
] as const;
|
||||
|
||||
export const agentAuditEvents = pgTable(
|
||||
'agent_audit_events',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
// Global append order; per-agent ordering is a filter on agent_id ordered
|
||||
// by seq.
|
||||
seq: bigint('seq', { mode: 'number' }).notNull().generatedAlwaysAsIdentity(),
|
||||
eventType: text('event_type').notNull(),
|
||||
// No FK: audit events outlive every principal and every target.
|
||||
actorId: text('actor_id').notNull(),
|
||||
agentId: uuid('agent_id').notNull(),
|
||||
correlationId: text('correlation_id').notNull(),
|
||||
causationId: uuid('causation_id').references((): AnyPgColumn => agentAuditEvents.id, {
|
||||
onDelete: 'restrict',
|
||||
}),
|
||||
// Immutable snapshot at event time; never carries credential material
|
||||
// (§3.1 rule 1: actor, agent id, harness, provider, name, credentialMode).
|
||||
payload: jsonb('payload').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('agent_audit_events_seq_idx').on(t.seq),
|
||||
index('agent_audit_events_agent_seq_idx').on(t.agentId, t.seq),
|
||||
index('agent_audit_events_correlation_idx').on(t.correlationId),
|
||||
check(
|
||||
'agent_audit_events_type_check',
|
||||
sql`event_type IN ('agent.enrolled', 'agent.enrollment.replayed')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const agentOutboxStatusEnum = pgEnum('agent_outbox_status', [
|
||||
'pending',
|
||||
'processing',
|
||||
'delivered',
|
||||
]);
|
||||
|
||||
export const agentOutbox = pgTable(
|
||||
'agent_outbox',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
// FK into the append-only events table: never dangles, RESTRICT is safe.
|
||||
eventId: uuid('event_id')
|
||||
.notNull()
|
||||
.references(() => agentAuditEvents.id, { onDelete: 'restrict' }),
|
||||
correlationId: text('correlation_id').notNull(),
|
||||
status: agentOutboxStatusEnum('status').notNull().default('pending'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('agent_outbox_event_idx').on(t.eventId),
|
||||
index('agent_outbox_status_created_idx').on(t.status, t.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
// Contract 3 §4.3 fence shape. Uniqueness is (operation, key); the recorded
|
||||
// replay mode is always 'actor-bound' for this family (`shared` is seed-only
|
||||
// and refused at validation — design §3.1), but the column keeps the ratified
|
||||
// envelope shape and serves the mode-mismatch collision check. The payload
|
||||
// digest input EXCLUDES the credential value (design §3.1 rule 5).
|
||||
export const agentIdempotencyFence = pgTable(
|
||||
'agent_idempotency_fence',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
operation: text('operation').notNull(),
|
||||
idempotencyKey: text('idempotency_key').notNull(),
|
||||
// No FK: fence rows outlive principals, mirroring the audit tables.
|
||||
actorId: text('actor_id').notNull(),
|
||||
authorizationScope: text('authorization_scope').notNull(),
|
||||
payloadDigest: text('payload_digest').notNull(),
|
||||
replayMode: text('replay_mode').notNull().default('actor-bound'),
|
||||
// Committed-outcome reference (the enrolled agent's id). Snapshot value,
|
||||
// no FK: the fence must keep answering replays after a legacy DELETE.
|
||||
outcomeAgentId: uuid('outcome_agent_id').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('agent_idempotency_fence_operation_key_idx').on(t.operation, t.idempotencyKey),
|
||||
check(
|
||||
'agent_idempotency_fence_replay_mode_check',
|
||||
sql`replay_mode IN ('actor-bound', 'shared')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -47,7 +47,7 @@ usage_error() {
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-i|--issue)
|
||||
-i|--issue|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
ISSUE="$2"
|
||||
shift 2
|
||||
|
||||
@@ -12,6 +12,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
# Parse arguments
|
||||
ISSUE_NUMBER=""
|
||||
COMMENT=""
|
||||
BODY_FILE=""
|
||||
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||
@@ -24,7 +25,7 @@ usage_error() {
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-i|--issue)
|
||||
-i|--issue|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
ISSUE_NUMBER="$2"
|
||||
shift 2
|
||||
@@ -36,11 +37,18 @@ while [[ $# -gt 0 ]]; do
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--body-file)
|
||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
||||
BODY_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: issue-close.sh -i <issue_number> [-b <comment>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -i, --issue Issue number (required)"
|
||||
echo " -n, --number Issue number (required; canonical)"
|
||||
echo " -i, --issue Alias for --number"
|
||||
echo " -b, --body Comment to add before closing (optional; canonical)"
|
||||
echo " -c, --comment Alias for --body"
|
||||
echo " -h, --help Show this help"
|
||||
@@ -54,6 +62,19 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
|
||||
# exclusive with an explicit --body/--comment value.
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
|
||||
if [[ "$BODY_FILE" == "-" ]]; then
|
||||
COMMENT=$(cat) || usage_error "could not read body from stdin"
|
||||
else
|
||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
||||
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||
usage_error "issue number is required (-i/--issue)"
|
||||
fi
|
||||
|
||||
@@ -31,6 +31,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
# Parse arguments
|
||||
ISSUE_NUMBER=""
|
||||
COMMENT=""
|
||||
BODY_FILE=""
|
||||
LOGIN_OVERRIDE=""
|
||||
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
@@ -45,7 +46,7 @@ usage_error() {
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-i|--issue)
|
||||
-i|--issue|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
ISSUE_NUMBER="$2"
|
||||
shift 2
|
||||
@@ -58,6 +59,12 @@ while [[ $# -gt 0 ]]; do
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--body-file)
|
||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
||||
BODY_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--login)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
LOGIN_OVERRIDE="$2"
|
||||
@@ -67,7 +74,8 @@ while [[ $# -gt 0 ]]; do
|
||||
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -i, --issue Issue number (required)"
|
||||
echo " -n, --number Issue number (required; canonical)"
|
||||
echo " -i, --issue Alias for --number"
|
||||
echo " -b, --body Comment text (required; canonical)"
|
||||
echo " -c, --comment Alias for --body"
|
||||
echo " -l, --login Override the detected Gitea tea login for this call"
|
||||
@@ -82,6 +90,19 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
|
||||
# exclusive with an explicit --body/--comment value.
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
|
||||
if [[ "$BODY_FILE" == "-" ]]; then
|
||||
COMMENT=$(cat) || usage_error "could not read body from stdin"
|
||||
else
|
||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
||||
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||
usage_error "issue number is required (-i/--issue)"
|
||||
fi
|
||||
|
||||
@@ -10,6 +10,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
# Default values
|
||||
TITLE=""
|
||||
BODY=""
|
||||
BODY_FILE=""
|
||||
LABELS=""
|
||||
MILESTONE=""
|
||||
INTERACTIVE=false
|
||||
@@ -100,6 +101,12 @@ while [[ $# -gt 0 ]]; do
|
||||
BODY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--body-file)
|
||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
||||
BODY_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--labels)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
LABELS="$2"
|
||||
@@ -124,6 +131,19 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# R3 (2026-08-29): resolve --body-file into BODY (file or stdin '-');
|
||||
# exclusive with an explicit --body/--comment value.
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
[[ -z "$BODY" ]] || usage_error "--body-file and --body are mutually exclusive"
|
||||
if [[ "$BODY_FILE" == "-" ]]; then
|
||||
BODY=$(cat) || usage_error "could not read body from stdin"
|
||||
else
|
||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
||||
BODY=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if [[ "$INTERACTIVE" == true ]]; then
|
||||
[[ -n "$TITLE" ]] || read -r -p "Issue title: " TITLE
|
||||
[[ -n "$BODY" ]] || read -r -p "Issue body (optional): " BODY || true
|
||||
|
||||
@@ -11,6 +11,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
ISSUE_NUMBER=""
|
||||
TITLE=""
|
||||
BODY=""
|
||||
BODY_FILE=""
|
||||
LABELS=""
|
||||
MILESTONE=""
|
||||
|
||||
@@ -25,7 +26,7 @@ usage_error() {
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-i|--issue)
|
||||
-i|--issue|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
ISSUE_NUMBER="$2"
|
||||
shift 2
|
||||
@@ -40,6 +41,12 @@ while [[ $# -gt 0 ]]; do
|
||||
BODY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--body-file)
|
||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
||||
BODY_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--labels)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
LABELS="$2"
|
||||
@@ -54,7 +61,8 @@ while [[ $# -gt 0 ]]; do
|
||||
echo "Usage: issue-edit.sh -i <issue_number> [-t <title>] [-b <body>] [-l <labels>] [-m <milestone>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -i, --issue Issue number (required)"
|
||||
echo " -n, --number Issue number (required; canonical)"
|
||||
echo " -i, --issue Alias for --number"
|
||||
echo " -t, --title New title"
|
||||
echo " -b, --body New body/description"
|
||||
echo " -l, --labels Labels (comma-separated, replaces existing)"
|
||||
@@ -70,6 +78,19 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# R3 (2026-08-29): resolve --body-file into BODY (file or stdin '-');
|
||||
# exclusive with an explicit --body/--comment value.
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
[[ -z "$BODY" ]] || usage_error "--body-file and --body are mutually exclusive"
|
||||
if [[ "$BODY_FILE" == "-" ]]; then
|
||||
BODY=$(cat) || usage_error "could not read body from stdin"
|
||||
else
|
||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
||||
BODY=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||
usage_error "issue number is required (-i/--issue)"
|
||||
fi
|
||||
|
||||
@@ -54,7 +54,7 @@ while [[ $# -gt 0 ]]; do
|
||||
STATE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--label)
|
||||
-l|--label|--labels)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
LABEL="$2"
|
||||
shift 2
|
||||
|
||||
@@ -11,6 +11,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
# Parse arguments
|
||||
ISSUE_NUMBER=""
|
||||
COMMENT=""
|
||||
BODY_FILE=""
|
||||
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||
@@ -23,7 +24,7 @@ usage_error() {
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-i|--issue)
|
||||
-i|--issue|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
ISSUE_NUMBER="$2"
|
||||
shift 2
|
||||
@@ -35,11 +36,18 @@ while [[ $# -gt 0 ]]; do
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--body-file)
|
||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
||||
BODY_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: issue-reopen.sh -i <issue_number> [-b <comment>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -i, --issue Issue number (required)"
|
||||
echo " -n, --number Issue number (required; canonical)"
|
||||
echo " -i, --issue Alias for --number"
|
||||
echo " -b, --body Comment to add when reopening (optional; canonical)"
|
||||
echo " -c, --comment Alias for --body"
|
||||
echo " -h, --help Show this help"
|
||||
@@ -53,6 +61,19 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
|
||||
# exclusive with an explicit --body/--comment value.
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
|
||||
if [[ "$BODY_FILE" == "-" ]]; then
|
||||
COMMENT=$(cat) || usage_error "could not read body from stdin"
|
||||
else
|
||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
||||
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||
usage_error "issue number is required (-i/--issue)"
|
||||
fi
|
||||
|
||||
@@ -81,7 +81,7 @@ if comments:
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-i|--issue)
|
||||
-i|--issue|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
ISSUE_NUMBER="$2"
|
||||
shift 2
|
||||
@@ -90,7 +90,8 @@ while [[ $# -gt 0 ]]; do
|
||||
echo "Usage: issue-view.sh -i <issue_number>"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -i, --issue Issue number (required)"
|
||||
echo " -n, --number Issue number (required; canonical)"
|
||||
echo " -i, --issue Alias for --number"
|
||||
echo ""
|
||||
echo "Comments are always included (tea --comments / Gitea API /comments)."
|
||||
echo " -h, --help Show this help"
|
||||
|
||||
@@ -11,6 +11,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
# Parse arguments
|
||||
PR_NUMBER=""
|
||||
COMMENT=""
|
||||
BODY_FILE=""
|
||||
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||
@@ -35,6 +36,12 @@ while [[ $# -gt 0 ]]; do
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--body-file)
|
||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
||||
BODY_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: pr-close.sh -n <pr_number> [-b <comment>]"
|
||||
echo ""
|
||||
@@ -53,6 +60,19 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
|
||||
# exclusive with an explicit --body/--comment value.
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
|
||||
if [[ "$BODY_FILE" == "-" ]]; then
|
||||
COMMENT=$(cat) || usage_error "could not read body from stdin"
|
||||
else
|
||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
||||
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if [[ -z "$PR_NUMBER" ]]; then
|
||||
usage_error "PR number is required (-n/--number)"
|
||||
fi
|
||||
|
||||
@@ -10,6 +10,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
# Default values
|
||||
TITLE=""
|
||||
BODY=""
|
||||
BODY_FILE=""
|
||||
BASE_BRANCH=""
|
||||
HEAD_BRANCH=""
|
||||
LABELS=""
|
||||
@@ -158,6 +159,12 @@ while [[ $# -gt 0 ]]; do
|
||||
BODY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--body-file)
|
||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
||||
BODY_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-B|--base)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
BASE_BRANCH="$2"
|
||||
@@ -197,6 +204,19 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# R3 (2026-08-29): resolve --body-file into BODY (file or stdin '-');
|
||||
# exclusive with an explicit --body/--comment value.
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
[[ -z "$BODY" ]] || usage_error "--body-file and --body are mutually exclusive"
|
||||
if [[ "$BODY_FILE" == "-" ]]; then
|
||||
BODY=$(cat) || usage_error "could not read body from stdin"
|
||||
else
|
||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
||||
BODY=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
# If no title but issue provided, generate title
|
||||
if [[ -z "$TITLE" ]] && [[ -n "$ISSUE" ]]; then
|
||||
TITLE="Fixes #$ISSUE"
|
||||
|
||||
@@ -13,21 +13,33 @@ OUTPUT_FILE=""
|
||||
REPO_OVERRIDE=""
|
||||
HOST_OVERRIDE=""
|
||||
|
||||
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
echo "Usage: pr-diff.sh -n <pr_number> [-r owner/repo] [--host host] [-o <output_file>] (see --help)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-n|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
PR_NUMBER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-o|--output)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
OUTPUT_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-r|--repo)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
REPO_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--host)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
HOST_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
@@ -40,18 +52,18 @@ while [[ $# -gt 0 ]]; do
|
||||
echo " --host Gitea host for --repo API calls (or set GITEA_HOST/GITEA_URL)"
|
||||
echo " -o, --output Output file (optional, prints to stdout if omitted)"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential failure."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
usage_error "unknown option: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$PR_NUMBER" ]]; then
|
||||
echo "Error: PR number is required (-n)" >&2
|
||||
exit 1
|
||||
usage_error "PR number is required (-n/--number)"
|
||||
fi
|
||||
|
||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||
|
||||
@@ -11,6 +11,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
PR_NUMBER=""
|
||||
TITLE=""
|
||||
BODY=""
|
||||
BODY_FILE=""
|
||||
BASE_BRANCH=""
|
||||
DRAFT_MODE=""
|
||||
LOGIN_OVERRIDE=""
|
||||
@@ -65,6 +66,7 @@ while [[ $# -gt 0 ]]; do
|
||||
-n|--number) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; PR_NUMBER="${2:-}"; shift 2 ;;
|
||||
-t|--title) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; TITLE="${2:-}"; shift 2 ;;
|
||||
-b|--body) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; BODY="${2:-}"; shift 2 ;;
|
||||
--body-file) [[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"; BODY_FILE="$2"; shift 2 ;;
|
||||
-B|--base) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; BASE_BRANCH="${2:-}"; shift 2 ;;
|
||||
--draft)
|
||||
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 2; }
|
||||
@@ -80,6 +82,19 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# R3 (2026-08-29): resolve --body-file into BODY (file or stdin '-');
|
||||
# exclusive with an explicit --body value.
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
[[ -z "$BODY" ]] || usage_error "--body-file and --body are mutually exclusive"
|
||||
if [[ "$BODY_FILE" == "-" ]]; then
|
||||
BODY=$(cat) || usage_error "could not read body from stdin"
|
||||
else
|
||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
||||
BODY=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
[[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 2; }
|
||||
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 2; }
|
||||
if [[ -z "$TITLE" && -z "$BODY" && -z "$BASE_BRANCH" && -z "$DRAFT_MODE" ]]; then
|
||||
|
||||
@@ -34,29 +34,41 @@ Examples:
|
||||
$(basename "$0") -s merged -a username # Merged PRs by user
|
||||
$(basename "$0") --repo ddk/ai-bma # List PRs from anywhere
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
exit "${1:-2}"
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
usage >&2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-s|--state)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
STATE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--label)
|
||||
-l|--label|--labels)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
LABEL="$2"
|
||||
shift 2
|
||||
;;
|
||||
-a|--author)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
AUTHOR="$2"
|
||||
shift 2
|
||||
;;
|
||||
-n|--limit)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
LIMIT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-r|--repo)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
REPO_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
@@ -64,8 +76,7 @@ while [[ $# -gt 0 ]]; do
|
||||
usage 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage
|
||||
usage_error "unknown option: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -12,13 +12,23 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
PR_NUMBER=""
|
||||
OUTPUT_FILE=""
|
||||
|
||||
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
echo "Usage: pr-metadata.sh -n <pr_number> [-o <output_file>] (see --help)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-n|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
PR_NUMBER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-o|--output)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
OUTPUT_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
@@ -29,18 +39,18 @@ while [[ $# -gt 0 ]]; do
|
||||
echo " -n, --number PR number (required)"
|
||||
echo " -o, --output Output file (optional, prints to stdout if omitted)"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential failure."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
exit 1
|
||||
usage_error "unknown option: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$PR_NUMBER" ]]; then
|
||||
echo "Error: PR number is required (-n)" >&2
|
||||
exit 1
|
||||
usage_error "PR number is required (-n/--number)"
|
||||
fi
|
||||
|
||||
write_metadata() {
|
||||
|
||||
@@ -39,6 +39,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
PR_NUMBER=""
|
||||
ACTION=""
|
||||
COMMENT=""
|
||||
BODY_FILE=""
|
||||
LOGIN_OVERRIDE=""
|
||||
REPO_OVERRIDE=""
|
||||
HOST_OVERRIDE=""
|
||||
@@ -71,6 +72,12 @@ while [[ $# -gt 0 ]]; do
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--body-file)
|
||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
||||
BODY_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--login)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
LOGIN_OVERRIDE="$2"
|
||||
@@ -108,6 +115,19 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
|
||||
# exclusive with an explicit --body/--comment value.
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
|
||||
if [[ "$BODY_FILE" == "-" ]]; then
|
||||
COMMENT=$(cat) || usage_error "could not read body from stdin"
|
||||
else
|
||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
||||
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if [[ -z "$PR_NUMBER" ]]; then
|
||||
usage_error "PR number is required (-n/--number)"
|
||||
fi
|
||||
|
||||
@@ -11,13 +11,23 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
PR_NUMBER=""
|
||||
REPO_OVERRIDE=""
|
||||
|
||||
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
echo "Usage: pr-view.sh -n <pr_number> [-r owner/repo] (see --help)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-n|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
PR_NUMBER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-r|--repo)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
REPO_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
@@ -28,18 +38,18 @@ while [[ $# -gt 0 ]]; do
|
||||
echo " -n, --number PR number (required)"
|
||||
echo " -r, --repo Repository slug (default: infer from git origin)"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential failure."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
usage_error "unknown option: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$PR_NUMBER" ]]; then
|
||||
echo "Error: PR number is required (-n)"
|
||||
exit 1
|
||||
usage_error "PR number is required (-n/--number)"
|
||||
fi
|
||||
|
||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||
|
||||
@@ -161,6 +161,30 @@ rc=0
|
||||
grep -q "GitHub comment write failed" "$ERR_FILE" || fail "GitHub path: normalized error missing from stderr"
|
||||
grep -q "^gh issue comment" "$PROBE_LOG" || fail "GitHub path: gh write was not invoked"
|
||||
|
||||
# R3 body-file arms (2026-08-29): --body-file <path> and '-' (stdin).
|
||||
BF_FILE="$WORK_DIR/body.md"
|
||||
printf 'line one\nline two\n' > "$BF_FILE"
|
||||
|
||||
# File loads the body: parse acceptance then credential-class failure
|
||||
# (sandboxed runner: rc nonzero and NOT 2).
|
||||
rc=0
|
||||
run_wrapper_sandboxed -i 5 --body-file "$BF_FILE" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "body-file arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "body-file arm misclassified credential failure as a usage error"
|
||||
|
||||
# Stdin form loads the body the same way.
|
||||
rc=0
|
||||
printf 'from stdin' | run_wrapper_sandboxed -i 5 --body-file - >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 && "$rc" -ne 2 ]] || fail "body-file stdin arm rc=$rc (want nonzero, not 2)"
|
||||
|
||||
# Mutually exclusive with --body: rc 2.
|
||||
expect_rc 2 "body-file + body exclusive" -i 5 --body-file "$BF_FILE" -b explicit
|
||||
expect_stderr "mutually exclusive" "exclusivity message on stderr"
|
||||
|
||||
# Missing file: rc 2 naming the path.
|
||||
expect_rc 2 "missing body file" -i 5 --body-file "$WORK_DIR/nope.md"
|
||||
expect_stderr "not readable" "missing-file message on stderr"
|
||||
|
||||
# 7. No provider contact from any usage-error arm (arm 6b's deliberate gh
|
||||
# invocation is the only permitted entry in the probe log).
|
||||
if grep -v '^gh issue comment' "$PROBE_LOG" | grep -q .; then
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for pr-diff.sh (R4, 2026-08-28).
|
||||
#
|
||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-diff-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-diff.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/pr-diff.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: pr-diff.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
expect_rc 2 "missing -n exits 2"
|
||||
expect_stderr "PR number is required" "missing -n message on stderr"
|
||||
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -n -o -r --number --output --repo --host; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -n --help
|
||||
expect_rc 2 "short flag value rejected" -n -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -n; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -n 5 >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
echo "pr-diff.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for pr-list.sh (R4, 2026-08-28).
|
||||
#
|
||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-list-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-list.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/pr-list.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: pr-list.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
|
||||
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -s -l -a -n -r --state --label --author --limit --repo; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -s --help
|
||||
expect_rc 2 "short flag value rejected" -s -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -s; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -s open >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
echo "pr-list.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for pr-metadata.sh (R4, 2026-08-28).
|
||||
#
|
||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-metadata.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/pr-metadata.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: pr-metadata.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
expect_rc 2 "missing -n exits 2"
|
||||
expect_stderr "PR number is required" "missing -n message on stderr"
|
||||
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -n -o --number --output; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -n --help
|
||||
expect_rc 2 "short flag value rejected" -n -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -n; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -n 5 >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
echo "pr-metadata.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for pr-view.sh (R4, 2026-08-28).
|
||||
#
|
||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-view-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-view.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/pr-view.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: pr-view.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
expect_rc 2 "missing -n exits 2"
|
||||
expect_stderr "PR number is required" "missing -n message on stderr"
|
||||
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -n -r --number --repo; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -n --help
|
||||
expect_rc 2 "short flag value rejected" -n -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -n; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -n 5 >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
echo "pr-view.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -35,6 +35,9 @@
|
||||
#
|
||||
# OPTIONS
|
||||
# -L NAME tmux socket name passed to `tmux -L NAME` on the target host
|
||||
#
|
||||
# Exit 4: local target session exists on multiple socket servers and no
|
||||
# -L / MOSAIC_TMUX_SOCKET disambiguated it (B1 stale-twin guard).
|
||||
# -s DST_SESSION target tmux session (or session:window.pane) [required]
|
||||
# -H SSH_TARGET ssh target (user@host) for a remote pane; omit for local
|
||||
# -n DST_HOST hostname to show in the preamble for the target.
|
||||
@@ -63,8 +66,10 @@
|
||||
# group 1 = src label group 2 = dst host:session
|
||||
# group 3 = class (absent => actionable) group 4 = message body
|
||||
#
|
||||
# EXIT CODES (passed through from send-message.sh)
|
||||
# EXIT CODES (passed through from send-message.sh, except 4)
|
||||
# 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error
|
||||
# 4 agent-send refusal: local target session exists on multiple socket
|
||||
# servers and no -L / MOSAIC_TMUX_SOCKET disambiguated it (B1)
|
||||
set -uo pipefail
|
||||
|
||||
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
@@ -154,6 +159,61 @@ FULL="${PREAMBLE} ${MSG}"
|
||||
B64=$(printf '%s' "$FULL" | base64 -w0)
|
||||
|
||||
vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v"
|
||||
|
||||
# Exact session matching for the sender target (codex PR #1466): without
|
||||
# '=', tmux target syntax accepts an unambiguous PREFIX, so a delivery
|
||||
# aimed at session X can land in X-old. Compound targets (session:win.pane)
|
||||
# and already-exact ('=...') forms pass through untouched. Computed BEFORE
|
||||
# socket discovery so the discovery probes use the same target semantics
|
||||
# (probing '==name' for an already-exact input was a false-negative hit).
|
||||
DST_TARGET="$DST_SESSION"
|
||||
case "$DST_SESSION" in
|
||||
=*) ;;
|
||||
*:*)
|
||||
# Compound target (session:win.pane): pin the SESSION component exact
|
||||
# (=session:win.pane); unpinned, the session part still prefix-matches
|
||||
# (codex PR #1466: 'agent:0.0' can resolve into 'agent-old').
|
||||
DST_TARGET="=${DST_SESSION%%:*}:${DST_SESSION#*:}"
|
||||
;;
|
||||
*) DST_TARGET="=$DST_SESSION" ;;
|
||||
esac
|
||||
|
||||
# Socket default resolution (B1, 2026-08-29). Precedence: explicit -L >
|
||||
# launcher-exported MOSAIC_TMUX_SOCKET > unique socket hit > refusal on
|
||||
# ambiguity > tmux default socket. The ambiguity refusal fires ONLY when
|
||||
# no explicit or env choice exists and the session name lives on multiple
|
||||
# servers (measured 2026-08-28/29: tasking sends landed in a stale
|
||||
# default-socket twin; rc 0 reported honest delivery to the wrong pane).
|
||||
# Socket discovery scans tmux's own socket dir, ${TMUX_TMPDIR:-/tmp}/tmux-UID
|
||||
# (codex PR #1466: TMPDIR is not where tmux keeps -L sockets).
|
||||
# MOSAIC_TMUX_SOCKET is LOCAL-host state (launcher-exported): it must not
|
||||
# leak into remote sends, where -L would target a socket on the remote
|
||||
# host (codex PR #1466).
|
||||
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ] && [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then
|
||||
SOCKET_NAME="$MOSAIC_TMUX_SOCKET"
|
||||
fi
|
||||
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ]; then
|
||||
socket_dir="${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)"
|
||||
hits=""
|
||||
for sf in "$socket_dir"/*; do
|
||||
[ -S "$sf" ] || continue
|
||||
sname="${sf##*/}"
|
||||
# '=' forces exact session-name matching: tmux target syntax otherwise
|
||||
# accepts an unambiguous PREFIX, so a session named X-old on a socket
|
||||
# would count as a false hit for target X (codex PR #1466).
|
||||
tmux -L "$sname" has-session -t "$DST_TARGET" 2>/dev/null && hits="$hits$sname"$'\n'
|
||||
done
|
||||
hit_count=$(printf '%s' "$hits" | grep -c . || true)
|
||||
if [ "$hit_count" -gt 1 ]; then
|
||||
echo "agent-send.sh: REFUSING - session '$DST_SESSION' exists on multiple sockets:" >&2
|
||||
printf ' %s\n' $hits >&2
|
||||
echo " Pass -L <socket> explicitly (or export MOSAIC_TMUX_SOCKET to disambiguate)." >&2
|
||||
exit 4
|
||||
elif [ "$hit_count" -eq 1 ]; then
|
||||
SOCKET_NAME="$(printf '%s' "$hits")"
|
||||
fi
|
||||
fi
|
||||
|
||||
socket_args=()
|
||||
if [ -n "$SOCKET_NAME" ]; then
|
||||
socket_args=(-L "$SOCKET_NAME")
|
||||
@@ -161,9 +221,9 @@ fi
|
||||
|
||||
if [ -z "$SSH_TARGET" ]; then
|
||||
# Local pane: call the canonical sender directly.
|
||||
exec "$SENDER" "${socket_args[@]}" -t "$DST_SESSION" -b "$B64" -r "$RETRIES" $vflag
|
||||
exec "$SENDER" "${socket_args[@]}" -t "$DST_TARGET" -b "$B64" -r "$RETRIES" $vflag
|
||||
else
|
||||
# Remote pane: ship the sender over ssh and run it local to the target.
|
||||
ssh -o ConnectTimeout=10 "$SSH_TARGET" \
|
||||
"bash -s -- ${socket_args[*]@Q} -t '$DST_SESSION' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
|
||||
"bash -s -- ${socket_args[*]@Q} -t '$DST_TARGET' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
|
||||
fi
|
||||
|
||||
@@ -8,7 +8,15 @@ SOCKET="mosaic-test-$RANDOM-$$"
|
||||
TARGET="target-$RANDOM"
|
||||
DEFAULT_TARGET="default-target-$RANDOM"
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; tmux kill-session -t "$DEFAULT_TARGET" >/dev/null 2>&1 || true; rm -rf "$TMPDIR"' EXIT
|
||||
ART_OUT=$(mktemp)
|
||||
AMB_OUT=$(mktemp)
|
||||
AMB_ERR=$(mktemp)
|
||||
A2_OUT=$(mktemp)
|
||||
A2_ERR=$(mktemp)
|
||||
UNIQ_OUT=$(mktemp)
|
||||
UNIQ_ERR=$(mktemp)
|
||||
TWIN="twin-$RANDOM-$$"
|
||||
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; tmux kill-session -t "$DEFAULT_TARGET" >/dev/null 2>&1 || true; tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true; rm -rf "$TMPDIR" $ART_OUT $AMB_OUT $AMB_ERR $A2_OUT $A2_ERR $UNIQ_OUT $UNIQ_ERR' EXIT
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
@@ -79,4 +87,83 @@ for i in $(seq 1 "$CONC_N"); do
|
||||
done
|
||||
done
|
||||
|
||||
# B1 (2026-08-29): socket default resolution in agent-send.sh. Measured
|
||||
# defect: tasking sends without -L landed in a stale default-socket twin of
|
||||
# the target seat; rc 0 reported honest delivery to the wrong pane.
|
||||
|
||||
# Arm A: session on MULTIPLE sockets, no -L -> refuse with rc 4 naming both.
|
||||
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
amb_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "must refuse" >$AMB_OUT 2>$AMB_ERR || amb_rc=$?
|
||||
[ "$amb_rc" -eq 4 ] || fail "ambiguity refusal: rc=$amb_rc want 4 (stderr: $(cat $AMB_ERR))"
|
||||
grep -q "multiple sockets" $AMB_ERR || fail "ambiguity refusal message missing socket list"
|
||||
grep -qF "$SOCKET" $AMB_ERR || fail "ambiguity refusal message does not name the test socket"
|
||||
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
|
||||
# Arm A2: with MOSAIC_TMUX_SOCKET exported, a twin session is NOT ambiguous:
|
||||
# the env var disambiguates by precedence (codex PR #1466 blocker).
|
||||
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
a2_rc=0
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" "$AGENT_SEND" -s "$TWIN" -m "env disambiguated" >$A2_OUT 2>$A2_ERR || a2_rc=$?
|
||||
[ "$a2_rc" -eq 0 ] || fail "env disambiguation: rc=$a2_rc (stderr: $(cat $A2_ERR))"
|
||||
sleep 0.2
|
||||
a2_pane="$(tmux -L "$SOCKET" capture-pane -t "=$TWIN:0.0" -p)" || fail "cannot capture twin (arm A2)"
|
||||
grep -qF "env disambiguated" <<<"$a2_pane" || fail "env disambiguation did not deliver on the named socket"
|
||||
a2_default="$(tmux capture-pane -t "=$TWIN:0.0" -p)" || true
|
||||
if grep -qF "env disambiguated" <<<"$a2_default"; then
|
||||
fail "env disambiguation cross-delivered to the default-socket twin"
|
||||
fi
|
||||
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
|
||||
# Arm B: session unique to ONE socket, no -L -> auto-resolve to that socket
|
||||
# and deliver there.
|
||||
# Arm A3: prefix matching must not produce false socket hits (codex PR
|
||||
# #1466): a session named TWIN-old must not count as a hit for target
|
||||
# TWIN (tmux target syntax prefix-matches without '=').
|
||||
PSEUDO="${TWIN}-old"
|
||||
tmux new-session -d -s "$PSEUDO" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
A3_ERR=$(mktemp)
|
||||
a3_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "prefix trap" >/dev/null 2>"$A3_ERR" || a3_rc=$?
|
||||
# TWIN exists nowhere (both twins killed after arm A2); with '=' the
|
||||
# PSEUDO session is not a hit, so the sender must fail target-not-found
|
||||
# (rc 1) instead of delivering into the prefix-named session.
|
||||
[ "$a3_rc" -eq 1 ] || fail "prefix false-hit: rc=$a3_rc want 1 (stderr: $(cat "$A3_ERR"))"
|
||||
a3_pane="$(tmux capture-pane -t "=$PSEUDO:0.0" -p 2>/dev/null)" || a3_pane=""
|
||||
if printf '%s' "$a3_pane" | grep -F "prefix trap" >/dev/null; then
|
||||
fail "delivery landed in the prefix-named session (false socket hit)"
|
||||
fi
|
||||
tmux kill-session -t "$PSEUDO" >/dev/null 2>&1 || true
|
||||
rm -f "$A3_ERR"
|
||||
|
||||
# Arm A4: compound targets pin the SESSION component exact (codex PR
|
||||
# #1466): 'TWIN:0.0' must not resolve into the prefix-named session.
|
||||
PSEUDO2="${TWIN}-old"
|
||||
tmux new-session -d -s "$PSEUDO2" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
A4_ERR=$(mktemp)
|
||||
a4_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN:0.0" -m "compound trap" >/dev/null 2>"$A4_ERR" || a4_rc=$?
|
||||
[ "$a4_rc" -eq 1 ] || fail "compound prefix false-hit: rc=$a4_rc want 1 (stderr: $(cat "$A4_ERR"))"
|
||||
a4_pane="$(tmux capture-pane -t "=$PSEUDO2:0.0" -p 2>/dev/null)" || a4_pane=""
|
||||
if printf '%s' "$a4_pane" | grep -F "compound trap" >/dev/null; then
|
||||
fail "compound delivery landed in the prefix-named session"
|
||||
fi
|
||||
tmux kill-session -t "$PSEUDO2" >/dev/null 2>&1 || true
|
||||
rm -f "$A4_ERR"
|
||||
|
||||
uniq_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TARGET" -m "autoresolved hello" >$UNIQ_OUT 2>$UNIQ_ERR || uniq_rc=$?
|
||||
[ "$uniq_rc" -eq 0 ] || fail "unique auto-resolution: rc=$uniq_rc (stderr: $(cat $UNIQ_ERR))"
|
||||
sleep 0.2
|
||||
auto_pane="$(capture_named)" || fail "could not capture named socket pane (arm B)"
|
||||
grep -qF "autoresolved hello" <<<"$auto_pane" || fail "auto-resolution did not deliver to the named-socket pane"
|
||||
default_pane2="$(capture_default)" || fail "could not capture default socket pane (arm B)"
|
||||
if grep -qF "autoresolved hello" <<<"$default_pane2"; then
|
||||
fail "auto-resolution cross-delivered to the default socket pane"
|
||||
fi
|
||||
|
||||
echo "ok - named tmux socket send tools"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,218 @@
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { registerAgentCommand, runEnroll, runGetEnrollment } from './agent.js';
|
||||
import { enrollAgent, fetchEnrollment } from '../tui/gateway-api.js';
|
||||
|
||||
/**
|
||||
* CLI-parity witness for the agent enrollment family (design
|
||||
* docs/plans/2026-08-29-agent-enrollment-command-design.md §5 item 10;
|
||||
* contract 5 §4.5): `mosaic agent enroll` / `mosaic agent enrollment <id>`
|
||||
* invoke the same gateway commands with the same request/result/error
|
||||
* contracts the web client uses. The gateway side of the same routes is
|
||||
* witnessed in apps/gateway/src/enrollment/enrollment-commands.integration.test.ts.
|
||||
*/
|
||||
|
||||
const gateway = 'https://gateway.example.test';
|
||||
const auth = { gateway, cookie: 'session=test' };
|
||||
|
||||
const agentBody = {
|
||||
id: '3fca4f6a-1111-4222-8333-444455556666',
|
||||
name: 'Nova',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-test',
|
||||
status: 'idle',
|
||||
harness: 'fake-harness',
|
||||
persona: null,
|
||||
ownerId: 'user-1',
|
||||
enrolledAt: '2026-08-29T00:00:00.000Z',
|
||||
createdAt: '2026-08-29T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const okResponse = (correlationId: string) =>
|
||||
new Response(JSON.stringify({ ok: true, correlationId, agent: agentBody }), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
describe('agent enrollment CLI registration', (): void => {
|
||||
it('registers enroll and enrollment subcommands under `mosaic agent`', () => {
|
||||
const program = new Command();
|
||||
const cmd = registerAgentCommand(program);
|
||||
const names = cmd.commands.map((c) => c.name());
|
||||
expect(names).toContain('enroll');
|
||||
expect(names).toContain('enrollment');
|
||||
});
|
||||
|
||||
it('the enroll subcommand exposes no argv flag that carries a credential value', () => {
|
||||
const program = new Command();
|
||||
const cmd = registerAgentCommand(program);
|
||||
const enroll = cmd.commands.find((c) => c.name() === 'enroll');
|
||||
expect(enroll).toBeDefined();
|
||||
const flags = (enroll as Command).options.map((o) => o.flags);
|
||||
// --credential selects the MODE only; the intake value arrives via stdin.
|
||||
expect(flags).toContain('--credential <mode>');
|
||||
for (const flag of flags) {
|
||||
expect(flag).not.toMatch(/value|key <secret>|api-key/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent.enroll CLI parity', (): void => {
|
||||
it('posts the §3.1 request shape for reference mode and surfaces the typed result', async () => {
|
||||
const fetchMock = vi.fn(async () => okResponse('corr-ref'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await runEnroll(auth, {
|
||||
gateway,
|
||||
harness: 'fake-harness',
|
||||
name: 'Nova',
|
||||
model: 'claude-test',
|
||||
provider: 'anthropic',
|
||||
credential: 'reference',
|
||||
idempotencyKey: '9c1a26be-0000-4000-8000-000000000001',
|
||||
correlationId: 'corr-ref',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe(`${gateway}/api/enrollment/agents`);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
harness: 'fake-harness',
|
||||
name: 'Nova',
|
||||
model: 'claude-test',
|
||||
provider: 'anthropic',
|
||||
credential: { mode: 'reference' },
|
||||
idempotencyKey: '9c1a26be-0000-4000-8000-000000000001',
|
||||
correlationId: 'corr-ref',
|
||||
});
|
||||
expect(log.mock.calls.flat().join('\n')).toContain(agentBody.id);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('intake mode reads the credential from the injected stdin reader, never argv', async () => {
|
||||
const fetchMock = vi.fn(async () => okResponse('corr-intake'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await runEnroll(
|
||||
auth,
|
||||
{
|
||||
gateway,
|
||||
harness: 'fake-harness',
|
||||
name: 'Nova',
|
||||
model: 'claude-test',
|
||||
provider: 'anthropic',
|
||||
credential: 'intake',
|
||||
idempotencyKey: '9c1a26be-0000-4000-8000-000000000002',
|
||||
},
|
||||
async () => 'stdin-provided-key',
|
||||
);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
const body = JSON.parse(init.body as string) as { credential: Record<string, string> };
|
||||
expect(body.credential).toEqual({
|
||||
mode: 'intake',
|
||||
type: 'api_key',
|
||||
value: 'stdin-provided-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('an empty intake credential refuses locally with no gateway call', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await runEnroll(
|
||||
auth,
|
||||
{
|
||||
gateway,
|
||||
harness: 'fake-harness',
|
||||
name: 'Nova',
|
||||
model: 'claude-test',
|
||||
provider: 'anthropic',
|
||||
credential: 'intake',
|
||||
},
|
||||
async () => '',
|
||||
);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves the gateway refusal contract (closed error enum + correlation) in the CLI error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
statusCode: 409,
|
||||
error: 'conflict',
|
||||
message: 'idempotency conflict',
|
||||
correlationId: 'corr-409',
|
||||
}),
|
||||
{ status: 409, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
enrollAgent(gateway, auth.cookie, {
|
||||
harness: 'fake-harness',
|
||||
name: 'Nova',
|
||||
model: 'claude-test',
|
||||
provider: 'anthropic',
|
||||
credential: { mode: 'reference' },
|
||||
idempotencyKey: '9c1a26be-0000-4000-8000-000000000003',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Failed to enroll agent (409): {"statusCode":409,"error":"conflict",' +
|
||||
'"message":"idempotency conflict","correlationId":"corr-409"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent.enrollment.get CLI parity', (): void => {
|
||||
it('reads one enrollment with the correlation envelope on the query string', async () => {
|
||||
const fetchMock = vi.fn(async () => okResponse('corr-get'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await runGetEnrollment(auth, agentBody.id, 'corr-get');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as unknown as [string];
|
||||
expect(url).toBe(`${gateway}/api/enrollment/agents/${agentBody.id}?correlationId=corr-get`);
|
||||
expect(log.mock.calls.flat().join('\n')).toContain('corr-get');
|
||||
});
|
||||
|
||||
it('preserves the folded not_found contract in the CLI error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
statusCode: 404,
|
||||
error: 'not_found',
|
||||
message: 'agent not found',
|
||||
correlationId: 'corr-404',
|
||||
}),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(fetchEnrollment(gateway, auth.cookie, agentBody.id)).rejects.toThrow(
|
||||
'Failed to get enrollment (404): {"statusCode":404,"error":"not_found",' +
|
||||
'"message":"agent not found","correlationId":"corr-404"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Command } from 'commander';
|
||||
import { registerFleetAgentCommands, type FleetCommandDeps } from './fleet.js';
|
||||
import { withAuth } from './with-auth.js';
|
||||
@@ -9,8 +10,10 @@ import {
|
||||
deleteAgentConfig,
|
||||
fetchProjects,
|
||||
fetchProviders,
|
||||
enrollAgent,
|
||||
fetchEnrollment,
|
||||
} from '../tui/gateway-api.js';
|
||||
import type { AgentConfigInfo } from '../tui/gateway-api.js';
|
||||
import type { AgentConfigInfo, EnrolledAgentInfo } from '../tui/gateway-api.js';
|
||||
|
||||
function formatAgent(a: AgentConfigInfo): string {
|
||||
const sys = a.isSystem ? ' [system]' : '';
|
||||
@@ -75,11 +78,140 @@ export function registerAgentCommand(program: Command, fleetDeps: FleetCommandDe
|
||||
},
|
||||
);
|
||||
|
||||
registerEnrollmentCommands(cmd);
|
||||
registerFleetAgentCommands(cmd, fleetDeps);
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ── Agent enrollment (design docs/plans/2026-08-29-agent-enrollment-command-design.md §3;
|
||||
// CLI parity bound by contract 5 §4.5) ──
|
||||
|
||||
export interface EnrollCommandOptions {
|
||||
gateway: string;
|
||||
harness: string;
|
||||
name: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
persona?: string;
|
||||
credential: string;
|
||||
idempotencyKey?: string;
|
||||
correlationId?: string;
|
||||
replayMode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an intake credential value from stdin. Never accepted via argv — a
|
||||
* process argument is world-readable in `ps` for the process lifetime.
|
||||
*/
|
||||
export async function readCredentialFromStdin(): Promise<string> {
|
||||
if (process.stdin.isTTY) {
|
||||
console.error('Enter API key, then press Enter and Ctrl-D:');
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks)
|
||||
.toString('utf8')
|
||||
.replace(/\r?\n$/, '');
|
||||
}
|
||||
|
||||
function showEnrollment(correlationId: string, agent: EnrolledAgentInfo): void {
|
||||
console.log(` ID: ${agent.id}`);
|
||||
console.log(` Name: ${agent.name}`);
|
||||
console.log(` Harness: ${agent.harness ?? '—'}`);
|
||||
console.log(` Provider: ${agent.provider}`);
|
||||
console.log(` Model: ${agent.model}`);
|
||||
console.log(` Status: ${agent.status}`);
|
||||
console.log(` Owner: ${agent.ownerId ?? '—'}`);
|
||||
console.log(` Enrolled: ${agent.enrolledAt ?? '—'}`);
|
||||
console.log(` Correlation: ${correlationId}`);
|
||||
}
|
||||
|
||||
export async function runEnroll(
|
||||
auth: { gateway: string; cookie: string },
|
||||
opts: EnrollCommandOptions,
|
||||
readSecret: () => Promise<string> = readCredentialFromStdin,
|
||||
): Promise<void> {
|
||||
if (opts.credential !== 'reference' && opts.credential !== 'intake') {
|
||||
console.error(`Unknown credential mode "${opts.credential}" (use reference or intake).`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let credential: { mode: 'reference' } | { mode: 'intake'; type: 'api_key'; value: string };
|
||||
if (opts.credential === 'intake') {
|
||||
const value = await readSecret();
|
||||
if (!value) {
|
||||
console.error('Intake credential requires a non-empty API key on stdin.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
credential = { mode: 'intake', type: 'api_key', value };
|
||||
} else {
|
||||
credential = { mode: 'reference' };
|
||||
}
|
||||
|
||||
const result = await enrollAgent(auth.gateway, auth.cookie, {
|
||||
harness: opts.harness,
|
||||
name: opts.name,
|
||||
model: opts.model,
|
||||
provider: opts.provider,
|
||||
...(opts.persona !== undefined ? { persona: opts.persona } : {}),
|
||||
credential,
|
||||
idempotencyKey: opts.idempotencyKey ?? randomUUID(),
|
||||
...(opts.correlationId !== undefined ? { correlationId: opts.correlationId } : {}),
|
||||
...(opts.replayMode !== undefined ? { replayMode: opts.replayMode } : {}),
|
||||
});
|
||||
|
||||
console.log(`Agent "${result.agent.name}" enrolled.\n`);
|
||||
showEnrollment(result.correlationId, result.agent);
|
||||
}
|
||||
|
||||
export async function runGetEnrollment(
|
||||
auth: { gateway: string; cookie: string },
|
||||
agentId: string,
|
||||
correlationId?: string,
|
||||
): Promise<void> {
|
||||
const result = await fetchEnrollment(auth.gateway, auth.cookie, agentId, correlationId);
|
||||
showEnrollment(result.correlationId, result.agent);
|
||||
}
|
||||
|
||||
export function registerEnrollmentCommands(cmd: Command): void {
|
||||
cmd
|
||||
.command('enroll')
|
||||
.description('Enroll an agent through the gateway enrollment command (agent.enroll)')
|
||||
.requiredOption('--harness <id>', 'Harness the agent runs on (must be registered)')
|
||||
.requiredOption('--name <name>', 'Agent display name')
|
||||
.requiredOption('--model <model>', 'Model identifier')
|
||||
.requiredOption('--provider <provider>', 'Provider the credential belongs to')
|
||||
.option('--persona <text>', 'Agent persona / system prompt')
|
||||
.option(
|
||||
'--credential <mode>',
|
||||
'Credential mode: "reference" (already stored) or "intake" (API key read from stdin, never argv)',
|
||||
'reference',
|
||||
)
|
||||
.option('--idempotency-key <uuid>', 'Idempotency key (generated when omitted)')
|
||||
.option('--correlation-id <uuid>', 'Correlation id to carry through the audit trail')
|
||||
.option('--replay-mode <mode>', 'Idempotency replay mode (actor-bound)')
|
||||
.action(async (opts: Omit<EnrollCommandOptions, 'gateway'>) => {
|
||||
const parent = cmd.opts<{ gateway: string }>();
|
||||
const auth = await withAuth(parent.gateway);
|
||||
await runEnroll(auth, { ...opts, gateway: parent.gateway });
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('enrollment <agentId>')
|
||||
.description('Read one enrolled agent (agent.enrollment.get; owner or admin)')
|
||||
.option('--correlation-id <uuid>', 'Correlation id to carry through the read')
|
||||
.action(async (agentId: string, opts: { correlationId?: string }) => {
|
||||
const parent = cmd.opts<{ gateway: string }>();
|
||||
const auth = await withAuth(parent.gateway);
|
||||
await runGetEnrollment(auth, agentId, opts.correlationId);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveAgent(
|
||||
gateway: string,
|
||||
cookie: string,
|
||||
|
||||
@@ -15,6 +15,17 @@ import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { fleetCommsSendArgs, registerCommsCommand, tmuxSendArgs } from './comms.js';
|
||||
|
||||
describe('arg translation', () => {
|
||||
// B2 (2026-08-29): when --socket is omitted, the CLI forwards NO -L and
|
||||
// agent-send.sh's own resolution governs (explicit -L > MOSAIC_TMUX_SOCKET
|
||||
// > unique hit > ambiguity refusal). Forwarding a guessed default here
|
||||
// would defeat that resolution and reintroduce the stale-twin defect.
|
||||
it('omits -L entirely when --socket is not given (tool-owned resolution)', () => {
|
||||
const args = tmuxSendArgs('orch-01', 'hello', {});
|
||||
expect(args).toEqual(['-s', 'orch-01', '-m', 'hello']);
|
||||
expect(args).not.toContain('-L');
|
||||
expect(args.join(' ')).not.toContain('-L');
|
||||
});
|
||||
|
||||
it('tmux path: -s/-C/-L/-f/-m per agent-send.sh getopts', () => {
|
||||
expect(tmuxSendArgs('orch-01', 'hello', {})).toEqual(['-s', 'orch-01', '-m', 'hello']);
|
||||
expect(
|
||||
|
||||
@@ -62,7 +62,10 @@ export function registerCommsCommand(program: Command): void {
|
||||
.description('send <target> [message...] — same-host tmux unless --site is given')
|
||||
.option('--class <class>', 'terminal-log | actionable | human | reaction | digest')
|
||||
.option('--file <path>', 'message body from file (same-host path only)')
|
||||
.option('--socket <name>', 'tmux socket for the same-host send (e.g. mosaic-fleet)')
|
||||
.option(
|
||||
'--socket <name>',
|
||||
'tmux socket for the same-host send (e.g. mosaic-fleet). OMIT it to let agent-send.sh resolve the socket (explicit -L > MOSAIC_TMUX_SOCKET > unique hit; ambiguity refuses with its exit 4).',
|
||||
)
|
||||
.option('--site <site>', 'route via fleet-comms to <site>/<target>')
|
||||
.option('--comms-repo <path>', 'fleet-comms checkout', defaultCommsRepo())
|
||||
.argument('<target>', 'destination seat (session name)')
|
||||
|
||||
@@ -106,6 +106,7 @@ describe('registerFleetCommand', () => {
|
||||
'init',
|
||||
'install',
|
||||
'install-systemd',
|
||||
'logins',
|
||||
'migrate-v1',
|
||||
'persona',
|
||||
'plan',
|
||||
@@ -172,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',
|
||||
|
||||
@@ -17,7 +17,7 @@ import { homedir, hostname, userInfo } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fleetAgentEnvDir } from '../fleet/brain-home.js';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import * as readline from 'node:readline';
|
||||
import type { Command } from 'commander';
|
||||
import YAML from 'yaml';
|
||||
@@ -1606,6 +1606,24 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
.option('--mosaic-home <path>', 'Mosaic home directory', paths.mosaicHome)
|
||||
.option('--roster <path>', 'Fleet roster path');
|
||||
|
||||
// fleet logins (P4 gap closure, 2026-08-29): project per-seat git tokens
|
||||
// from the brain into usable credentials. The implementation is the
|
||||
// framework fleet tool; args pass through verbatim (--apply --seat X).
|
||||
cmd
|
||||
.command('logins')
|
||||
.description('Per-seat git credentials: seat-logins.sh pass-through (--apply --seat <name>)')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((...rest: unknown[]) => {
|
||||
const cmdObj = rest[rest.length - 1] as { args: string[] };
|
||||
const script = `${frameworkRoot}/tools/fleet/seat-logins.sh`;
|
||||
const result = spawnSync('bash', [script, ...cmdObj.args], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
process.exit(result.status ?? 125);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('init')
|
||||
.description('Initialize a local fleet roster')
|
||||
|
||||
@@ -562,6 +562,74 @@ export async function fetchInteractionHealth(gatewayUrl: string): Promise<unknow
|
||||
return handleResponse<unknown>(res, 'Failed to get interaction readiness');
|
||||
}
|
||||
|
||||
// ── Agent Enrollment types (design docs/plans/2026-08-29-agent-enrollment-command-design.md §3) ──
|
||||
|
||||
export interface EnrolledAgentInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
status: string;
|
||||
harness: string | null;
|
||||
persona: string | null;
|
||||
ownerId: string | null;
|
||||
enrolledAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface EnrollAgentRequest {
|
||||
harness: string;
|
||||
name: string;
|
||||
persona?: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
credential: { mode: 'reference' } | { mode: 'intake'; type: 'api_key'; value: string };
|
||||
idempotencyKey: string;
|
||||
correlationId?: string;
|
||||
replayMode?: string;
|
||||
}
|
||||
|
||||
export interface EnrollmentOutcome {
|
||||
ok: true;
|
||||
correlationId: string;
|
||||
agent: EnrolledAgentInfo;
|
||||
}
|
||||
|
||||
// ── Agent Enrollment endpoints ──
|
||||
|
||||
/**
|
||||
* agent.enroll. The gateway's typed refusal body (closed error enum +
|
||||
* correlationId) is preserved verbatim in the thrown error, so the CLI
|
||||
* surfaces the same contract the web client receives.
|
||||
*/
|
||||
export async function enrollAgent(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
data: EnrollAgentRequest,
|
||||
): Promise<EnrollmentOutcome> {
|
||||
const res = await fetch(`${gatewayUrl}/api/enrollment/agents`, {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<EnrollmentOutcome>(res, 'Failed to enroll agent');
|
||||
}
|
||||
|
||||
/** agent.enrollment.get: owner-or-admin read of one enrolled agent. */
|
||||
export async function fetchEnrollment(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
agentId: string,
|
||||
correlationId?: string,
|
||||
): Promise<EnrollmentOutcome> {
|
||||
const params = correlationId ? `?${new URLSearchParams({ correlationId }).toString()}` : '';
|
||||
const res = await fetch(
|
||||
`${gatewayUrl}/api/enrollment/agents/${encodeURIComponent(agentId)}${params}`,
|
||||
{ headers: headers(sessionCookie, gatewayUrl) },
|
||||
);
|
||||
return handleResponse<EnrollmentOutcome>(res, 'Failed to get enrollment');
|
||||
}
|
||||
|
||||
// ── Conversation Message types ──
|
||||
|
||||
export interface ConversationMessage {
|
||||
|
||||
Reference in New Issue
Block a user