fix: enforce atomic agent registration contract

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 15:38:40 -04:00
1 parent 47d323c17b
commit f64d00673c
58 files changed
+2692 -1667

No files matched your search

+8 -1
View File
@@ -47,7 +47,14 @@ CREATE TABLE IF NOT EXISTS agents (
org_id TEXT NOT NULL,
display_name TEXT NOT NULL,
responsible_entity TEXT NOT NULL,
integration_type TEXT NOT NULL DEFAULT 'sdk',
integration_type TEXT NOT NULL
CONSTRAINT agents_integration_type_check
CHECK (integration_type IN (
'augment', 'claudecode', 'cline', 'codex', 'copilot',
'cursor', 'droid', 'gemini', 'grok', 'kimi', 'kirocli',
'kiroide', 'letta', 'opencode', 'qwen', 'enterprise',
'gui', 'sdk', 'other'
)),
status TEXT NOT NULL DEFAULT 'active',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
@@ -0,0 +1,28 @@
-- Require every agent to declare a supported integration type.
BEGIN;
ALTER TABLE agents
ALTER COLUMN integration_type DROP DEFAULT;
ALTER TABLE agents
DROP CONSTRAINT IF EXISTS agents_integration_type_check;
ALTER TABLE agents
ADD CONSTRAINT agents_integration_type_check
CHECK (integration_type IN (
'augment', 'claudecode', 'cline', 'codex', 'copilot',
'cursor', 'droid', 'gemini', 'grok', 'kimi', 'kirocli',
'kiroide', 'letta', 'opencode', 'qwen', 'enterprise',
'gui', 'sdk', 'other'
));
INSERT INTO schema_versions (version, applied_at, description)
VALUES (
2,
EXTRACT(EPOCH FROM NOW())::BIGINT * 1000,
'Require a supported agent integration type'
)
ON CONFLICT (version) DO NOTHING;
COMMIT;
+1
View File
@@ -14,6 +14,7 @@
"start": "node dist/index.js",
"start:worker": "node dist/worker.js",
"start:scheduler": "node dist/scheduler.js",
"test": "npm run build && node --test test/*.test.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
+1 -5
View File
@@ -98,17 +98,13 @@ agents.patch(
const agentId = c.req.param('agent_id');
const body = await c.req.json<UpdateAgentRequest>();
if (!body || !body.integration_type || typeof body.integration_type !== 'string') {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingIntegrationType' });
}
const orgId = c.get('org_id');
const actor = c.get('actor');
const result = await agentService.updateAgentIntegrationType(
c.env.ELYDORA_DB,
agentId,
body.integration_type,
body?.integration_type,
orgId,
actor,
);
@@ -0,0 +1,86 @@
import {
INTEGRATION_TYPES,
type AdminAction,
type Agent,
type IntegrationType,
} from '../shared/index.js';
import type { Database, PreparedStatement } from '../adapters/interfaces.js';
import { AppError } from '../middleware/error-handler.js';
import { generateUUIDv7 } from '../utils/uuid.js';
export function requireIntegrationType(value: unknown): IntegrationType {
if (typeof value !== 'string' || value.length === 0) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingIntegrationType' });
}
if (!(INTEGRATION_TYPES as readonly string[]).includes(value)) {
throw new AppError(
400,
'VALIDATION_ERROR',
{
key: 'agent.invalidIntegrationType',
params: { value, valid: INTEGRATION_TYPES.join(', ') },
},
);
}
return value as IntegrationType;
}
export async function updateAgentIntegrationType(
db: Database,
agentId: string,
integrationValue: unknown,
orgId: string,
actor: string,
): Promise<{ agent: Agent }> {
const integrationType = requireIntegrationType(integrationValue);
const now = Date.now();
const agentRow = await db
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
.bind(agentId, orgId)
.first<Agent>();
if (!agentRow) {
throw new AppError(404, 'NOT_FOUND', { key: 'agent.notFound', params: { id: agentId } });
}
const statements: PreparedStatement[] = [];
statements.push(
db
.prepare('UPDATE agents SET integration_type = ?, updated_at = ? WHERE agent_id = ? AND org_id = ?')
.bind(integrationType, now, agentId, orgId),
);
const eventId = generateUUIDv7();
const action: AdminAction = 'agent.update';
statements.push(
db
.prepare(
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
eventId,
orgId,
actor,
action,
'agent',
agentId,
JSON.stringify({
integration_type: integrationType,
previous_integration_type: agentRow.integration_type,
}),
now,
),
);
await db.batch(statements);
return {
agent: {
...agentRow,
integration_type: integrationType,
updated_at: now,
},
};
}
+5 -76
View File
@@ -18,6 +18,9 @@ import { generateUUIDv7 } from '../utils/uuid.js';
import { base64urlDecode } from '../utils/crypto.js';
import { AppError } from '../middleware/error-handler.js';
import type { Database, PreparedStatement } from '../adapters/interfaces.js';
import { requireIntegrationType } from './agent-integration.js';
export { updateAgentIntegrationType } from './agent-integration.js';
// ---------------------------------------------------------------------------
// Register agent
@@ -29,6 +32,7 @@ export async function registerAgent(
orgId: string,
actor: string,
): Promise<RegisterAgentResponse> {
const integrationType = requireIntegrationType(body.integration_type);
const now = Date.now();
// Check if agent already exists
@@ -68,7 +72,7 @@ export async function registerAgent(
org_id: orgId,
display_name: body.display_name ?? body.agent_id,
responsible_entity: body.responsible_entity ?? '',
integration_type: body.integration_type ?? 'sdk',
integration_type: integrationType,
status: 'active',
created_at: now,
updated_at: now,
@@ -197,81 +201,6 @@ export async function getAgent(
return { agent: agentRow, keys };
}
// ---------------------------------------------------------------------------
// Update agent integration type
// ---------------------------------------------------------------------------
const VALID_INTEGRATION_TYPES = [
'sdk', 'claudecode', 'cursor', 'gemini', 'kirocli', 'kiroide',
'opencode', 'copilot', 'letta', 'codex', 'kimi',
'enterprise', 'gui', 'other',
] as const;
export async function updateAgentIntegrationType(
db: Database,
agentId: string,
integrationValue: string,
orgId: string,
actor: string,
): Promise<{ agent: Agent }> {
const now = Date.now();
const agentRow = await db
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
.bind(agentId, orgId)
.first<Agent>();
if (!agentRow) {
throw new AppError(404, 'NOT_FOUND', { key: 'agent.notFound', params: { id: agentId } });
}
if (!(VALID_INTEGRATION_TYPES as readonly string[]).includes(integrationValue)) {
throw new AppError(
400,
'VALIDATION_ERROR',
{ key: 'agent.invalidIntegrationType', params: { value: integrationValue, valid: VALID_INTEGRATION_TYPES.join(', ') } },
);
}
const statements: PreparedStatement[] = [];
statements.push(
db
.prepare('UPDATE agents SET integration_type = ?, updated_at = ? WHERE agent_id = ?')
.bind(integrationValue, now, agentId),
);
const eventId = generateUUIDv7();
const action: AdminAction = 'agent.update';
statements.push(
db
.prepare(
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
eventId,
orgId,
actor,
action,
'agent',
agentId,
JSON.stringify({ integration_type: integrationValue, previous_integration_type: agentRow.integration_type }),
now,
),
);
await db.batch(statements);
const updatedAgent: Agent = {
...agentRow,
integration_type: integrationValue,
updated_at: now,
};
return { agent: updatedAgent };
}
// ---------------------------------------------------------------------------
// Freeze agent
// ---------------------------------------------------------------------------
+3
View File
@@ -3,6 +3,7 @@ export type {
KeyStatus,
ExportStatus,
AdminAction,
IntegrationType,
RbacRole,
ErrorCode,
EOR,
@@ -51,6 +52,8 @@ export type {
ErrorResponse,
} from './types/index.js';
export { INTEGRATION_TYPES } from './types/index.js';
export {
ERROR_CODES,
MAX_PAYLOAD_SIZE,
+3 -3
View File
@@ -1,5 +1,5 @@
import type { Agent, AgentKey, Epoch, Export, Operation, Organization, Receipt, User } from './entities.js';
import type { ErrorCode } from './enums.js';
import type { ErrorCode, IntegrationType } from './enums.js';
import type { EAR, EOR } from './protocol.js';
// ---------------------------------------------------------------------------
@@ -10,7 +10,7 @@ export interface RegisterAgentRequest {
readonly agent_id: string;
readonly display_name?: string;
readonly responsible_entity?: string;
readonly integration_type?: string;
readonly integration_type: IntegrationType;
readonly keys: ReadonlyArray<{
readonly kid: string;
readonly public_key: string;
@@ -46,7 +46,7 @@ export interface RevokeAgentRequest {
}
export interface UpdateAgentRequest {
readonly integration_type: string;
readonly integration_type: IntegrationType;
}
// ---------------------------------------------------------------------------
+2 -2
View File
@@ -1,4 +1,4 @@
import type { AgentStatus, ExportStatus, KeyStatus, RbacRole } from './enums.js';
import type { AgentStatus, ExportStatus, IntegrationType, KeyStatus, RbacRole } from './enums.js';
/** Registered AI agent within an organization */
export interface Agent {
@@ -6,7 +6,7 @@ export interface Agent {
readonly org_id: string;
readonly display_name: string;
readonly responsible_entity: string;
readonly integration_type: string;
readonly integration_type: IntegrationType;
readonly status: AgentStatus;
readonly created_at: number;
readonly updated_at: number;
+23 -4
View File
@@ -19,10 +19,29 @@ export type AdminAction =
| 'export.create';
/** Supported agent integration types */
export type IntegrationType =
| 'claudecode' | 'cursor' | 'gemini' | 'kirocli' | 'kiroide'
| 'opencode' | 'copilot' | 'letta' | 'codex' | 'kimi'
| 'enterprise' | 'gui' | 'sdk' | 'other';
export const INTEGRATION_TYPES = [
'augment',
'claudecode',
'cline',
'codex',
'copilot',
'cursor',
'droid',
'gemini',
'grok',
'kimi',
'kirocli',
'kiroide',
'letta',
'opencode',
'qwen',
'enterprise',
'gui',
'sdk',
'other',
] as const;
export type IntegrationType = (typeof INTEGRATION_TYPES)[number];
/** RBAC roles for console access */
export type RbacRole =
@@ -3,10 +3,13 @@ export type {
KeyStatus,
ExportStatus,
AdminAction,
IntegrationType,
RbacRole,
ErrorCode,
} from './enums.js';
export { INTEGRATION_TYPES } from './enums.js';
export type {
EOR,
ECH,
@@ -0,0 +1,163 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { INTEGRATION_TYPES } from '../dist/shared/index.js';
import {
registerAgent,
updateAgentIntegrationType,
} from '../dist/services/agent-service.js';
const EXPECTED_INTEGRATION_TYPES = [
'augment', 'claudecode', 'cline', 'codex', 'copilot', 'cursor', 'droid',
'gemini', 'grok', 'kimi', 'kirocli', 'kiroide', 'letta', 'opencode', 'qwen',
'enterprise', 'gui', 'sdk', 'other',
];
const PUBLIC_KEY = Buffer.alloc(32, 7).toString('base64url');
class FakeStatement {
constructor(database, sql, values = []) {
this.database = database;
this.sql = sql;
this.values = values;
}
bind(...values) {
return new FakeStatement(this.database, this.sql, values);
}
async first() {
return this.database.firstResult;
}
async all() {
return { results: [] };
}
async run() {
return { success: true };
}
}
class FakeDatabase {
constructor(firstResult = null) {
this.firstResult = firstResult;
this.prepareCount = 0;
this.batches = [];
}
prepare(sql) {
this.prepareCount += 1;
return new FakeStatement(this, sql);
}
async batch(statements) {
this.batches.push(statements);
return statements.map(() => ({ results: [], success: true }));
}
}
function registrationRequest(integrationType) {
return {
agent_id: `agent-${integrationType}`,
integration_type: integrationType,
display_name: 'Audit Agent',
responsible_entity: 'platform@example.com',
keys: [{ kid: 'key-v1', public_key: PUBLIC_KEY, algorithm: 'ed25519' }],
};
}
async function expectIntegrationError(operation, messageKey) {
await assert.rejects(operation, (error) => {
assert.equal(error.name, 'AppError');
assert.equal(error.statusCode, 400);
assert.equal(error.errorCode, 'VALIDATION_ERROR');
assert.equal(error.messageKey, messageKey);
return true;
});
}
test('integration types match the public registration contract', () => {
assert.deepEqual(INTEGRATION_TYPES, EXPECTED_INTEGRATION_TYPES);
});
test('registration rejects missing and unknown integration types before database access', async () => {
for (const [value, messageKey] of [
[undefined, 'agent.missingIntegrationType'],
['future-cli', 'agent.invalidIntegrationType'],
]) {
const database = new FakeDatabase();
const request = registrationRequest(value);
await expectIntegrationError(
registerAgent(database, request, 'org-1', 'user-1'),
messageKey,
);
assert.equal(database.prepareCount, 0);
assert.equal(database.batches.length, 0);
}
for (const [value, messageKey] of [
[undefined, 'agent.missingIntegrationType'],
['future-cli', 'agent.invalidIntegrationType'],
]) {
const database = new FakeDatabase();
await expectIntegrationError(
updateAgentIntegrationType(database, 'agent-1', value, 'org-1', 'user-1'),
messageKey,
);
assert.equal(database.prepareCount, 0);
}
});
test('integration updates stay scoped to the authenticated organization', async () => {
const existingAgent = {
agent_id: 'agent-1',
org_id: 'org-1',
display_name: 'Audit Agent',
responsible_entity: 'platform@example.com',
integration_type: 'sdk',
status: 'active',
created_at: 1,
updated_at: 1,
};
for (const integrationType of INTEGRATION_TYPES) {
const database = new FakeDatabase(existingAgent);
const response = await updateAgentIntegrationType(
database,
existingAgent.agent_id,
integrationType,
existingAgent.org_id,
'user-1',
);
assert.equal(response.agent.integration_type, integrationType);
const update = database.batches[0].find((statement) => (
statement.sql.includes('UPDATE agents SET integration_type')
));
assert.ok(update);
assert.match(update.sql, /WHERE agent_id = \? AND org_id = \?/);
assert.equal(update.values[0], integrationType);
assert.equal(update.values[2], existingAgent.agent_id);
assert.equal(update.values[3], existingAgent.org_id);
}
});
test('registration persists every supported integration type without substitution', async () => {
for (const integrationType of INTEGRATION_TYPES) {
const database = new FakeDatabase();
const response = await registerAgent(
database,
registrationRequest(integrationType),
'org-1',
'user-1',
);
assert.equal(response.agent.integration_type, integrationType);
assert.equal(database.batches.length, 1);
const agentInsert = database.batches[0].find((statement) => (
statement.sql.includes('INSERT INTO agents')
));
assert.ok(agentInsert);
assert.equal(agentInsert.values[4], integrationType);
}
});