fix: enforce atomic agent registration contract
This commit is contained in:
1 parent
47d323c17b
commit
f64d00673c
58 files changed
+2692
-1667
No files matched your search
@@ -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;
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user