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
@@ -189,6 +189,7 @@ agent, err := client.RegisterAgent(&elydora.RegisterAgentRequest{
|
||||
AgentID: "my-agent",
|
||||
DisplayName: "My Agent",
|
||||
ResponsibleEntity: "team@example.com",
|
||||
IntegrationType: elydora.IntegrationTypeSDK,
|
||||
Keys: []elydora.RegisterAgentKeyInput{
|
||||
{KID: "key-v1", PublicKey: "<base64url>", Algorithm: "ed25519"},
|
||||
},
|
||||
|
||||
@@ -7,6 +7,13 @@ import (
|
||||
|
||||
// RegisterAgent registers a new AI agent within the organization.
|
||||
func (c *Client) RegisterAgent(req *RegisterAgentRequest) (*RegisterAgentResponse, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("elydora: register agent request must not be nil")
|
||||
}
|
||||
if !req.IntegrationType.IsValid() {
|
||||
return nil, fmt.Errorf("elydora: invalid integration_type %q", req.IntegrationType)
|
||||
}
|
||||
|
||||
var result RegisterAgentResponse
|
||||
if err := c.doPost("/v1/agents/register", req, &result); err != nil {
|
||||
return nil, err
|
||||
|
||||
+124
-70
@@ -20,6 +20,58 @@ const (
|
||||
KeyStatusRevoked KeyStatus = "revoked"
|
||||
)
|
||||
|
||||
type IntegrationType string
|
||||
|
||||
const (
|
||||
IntegrationTypeAugment IntegrationType = "augment"
|
||||
IntegrationTypeClaudecode IntegrationType = "claudecode"
|
||||
IntegrationTypeCline IntegrationType = "cline"
|
||||
IntegrationTypeCodex IntegrationType = "codex"
|
||||
IntegrationTypeCopilot IntegrationType = "copilot"
|
||||
IntegrationTypeCursor IntegrationType = "cursor"
|
||||
IntegrationTypeDroid IntegrationType = "droid"
|
||||
IntegrationTypeGemini IntegrationType = "gemini"
|
||||
IntegrationTypeGrok IntegrationType = "grok"
|
||||
IntegrationTypeKimi IntegrationType = "kimi"
|
||||
IntegrationTypeKiroCLI IntegrationType = "kirocli"
|
||||
IntegrationTypeKiroIDE IntegrationType = "kiroide"
|
||||
IntegrationTypeLetta IntegrationType = "letta"
|
||||
IntegrationTypeOpenCode IntegrationType = "opencode"
|
||||
IntegrationTypeQwen IntegrationType = "qwen"
|
||||
IntegrationTypeEnterprise IntegrationType = "enterprise"
|
||||
IntegrationTypeGUI IntegrationType = "gui"
|
||||
IntegrationTypeSDK IntegrationType = "sdk"
|
||||
IntegrationTypeOther IntegrationType = "other"
|
||||
)
|
||||
|
||||
// IsValid reports whether the integration type belongs to the public registration contract.
|
||||
func (integrationType IntegrationType) IsValid() bool {
|
||||
switch integrationType {
|
||||
case IntegrationTypeAugment,
|
||||
IntegrationTypeClaudecode,
|
||||
IntegrationTypeCline,
|
||||
IntegrationTypeCodex,
|
||||
IntegrationTypeCopilot,
|
||||
IntegrationTypeCursor,
|
||||
IntegrationTypeDroid,
|
||||
IntegrationTypeGemini,
|
||||
IntegrationTypeGrok,
|
||||
IntegrationTypeKimi,
|
||||
IntegrationTypeKiroCLI,
|
||||
IntegrationTypeKiroIDE,
|
||||
IntegrationTypeLetta,
|
||||
IntegrationTypeOpenCode,
|
||||
IntegrationTypeQwen,
|
||||
IntegrationTypeEnterprise,
|
||||
IntegrationTypeGUI,
|
||||
IntegrationTypeSDK,
|
||||
IntegrationTypeOther:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ExportStatus string
|
||||
|
||||
const (
|
||||
@@ -32,30 +84,30 @@ const (
|
||||
type RbacRole string
|
||||
|
||||
const (
|
||||
RbacRoleOrgOwner RbacRole = "org_owner"
|
||||
RbacRoleSecurityAdmin RbacRole = "security_admin"
|
||||
RbacRoleComplianceAuditor RbacRole = "compliance_auditor"
|
||||
RbacRoleReadonlyInvestigator RbacRole = "readonly_investigator"
|
||||
RbacRoleIntegrationEngineer RbacRole = "integration_engineer"
|
||||
RbacRoleOrgOwner RbacRole = "org_owner"
|
||||
RbacRoleSecurityAdmin RbacRole = "security_admin"
|
||||
RbacRoleComplianceAuditor RbacRole = "compliance_auditor"
|
||||
RbacRoleReadonlyInvestigator RbacRole = "readonly_investigator"
|
||||
RbacRoleIntegrationEngineer RbacRole = "integration_engineer"
|
||||
)
|
||||
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
ErrorCodeInvalidSignature ErrorCode = "INVALID_SIGNATURE"
|
||||
ErrorCodeUnknownAgent ErrorCode = "UNKNOWN_AGENT"
|
||||
ErrorCodeKeyRevoked ErrorCode = "KEY_REVOKED"
|
||||
ErrorCodeAgentFrozen ErrorCode = "AGENT_FROZEN"
|
||||
ErrorCodeTTLExpired ErrorCode = "TTL_EXPIRED"
|
||||
ErrorCodeReplayDetected ErrorCode = "REPLAY_DETECTED"
|
||||
ErrorCodePrevHashMismatch ErrorCode = "PREV_HASH_MISMATCH"
|
||||
ErrorCodePayloadTooLarge ErrorCode = "PAYLOAD_TOO_LARGE"
|
||||
ErrorCodeRateLimited ErrorCode = "RATE_LIMITED"
|
||||
ErrorCodeInternalError ErrorCode = "INTERNAL_ERROR"
|
||||
ErrorCodeUnauthorized ErrorCode = "UNAUTHORIZED"
|
||||
ErrorCodeForbidden ErrorCode = "FORBIDDEN"
|
||||
ErrorCodeNotFound ErrorCode = "NOT_FOUND"
|
||||
ErrorCodeValidationError ErrorCode = "VALIDATION_ERROR"
|
||||
ErrorCodeInvalidSignature ErrorCode = "INVALID_SIGNATURE"
|
||||
ErrorCodeUnknownAgent ErrorCode = "UNKNOWN_AGENT"
|
||||
ErrorCodeKeyRevoked ErrorCode = "KEY_REVOKED"
|
||||
ErrorCodeAgentFrozen ErrorCode = "AGENT_FROZEN"
|
||||
ErrorCodeTTLExpired ErrorCode = "TTL_EXPIRED"
|
||||
ErrorCodeReplayDetected ErrorCode = "REPLAY_DETECTED"
|
||||
ErrorCodePrevHashMismatch ErrorCode = "PREV_HASH_MISMATCH"
|
||||
ErrorCodePayloadTooLarge ErrorCode = "PAYLOAD_TOO_LARGE"
|
||||
ErrorCodeRateLimited ErrorCode = "RATE_LIMITED"
|
||||
ErrorCodeInternalError ErrorCode = "INTERNAL_ERROR"
|
||||
ErrorCodeUnauthorized ErrorCode = "UNAUTHORIZED"
|
||||
ErrorCodeForbidden ErrorCode = "FORBIDDEN"
|
||||
ErrorCodeNotFound ErrorCode = "NOT_FOUND"
|
||||
ErrorCodeValidationError ErrorCode = "VALIDATION_ERROR"
|
||||
)
|
||||
|
||||
type ExportFormat string
|
||||
@@ -70,13 +122,14 @@ const (
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Agent struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ResponsibleEntity string `json:"responsible_entity"`
|
||||
Status AgentStatus `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
AgentID string `json:"agent_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ResponsibleEntity string `json:"responsible_entity"`
|
||||
IntegrationType IntegrationType `json:"integration_type"`
|
||||
Status AgentStatus `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AgentKey struct {
|
||||
@@ -90,23 +143,23 @@ type AgentKey struct {
|
||||
}
|
||||
|
||||
type Operation struct {
|
||||
OperationID string `json:"operation_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
SeqNo int64 `json:"seq_no"`
|
||||
OperationType string `json:"operation_type"`
|
||||
IssuedAt int64 `json:"issued_at"`
|
||||
TTLMs int64 `json:"ttl_ms"`
|
||||
Nonce string `json:"nonce"`
|
||||
Subject string `json:"subject"`
|
||||
Action string `json:"action"`
|
||||
PayloadHash string `json:"payload_hash"`
|
||||
PrevChainHash string `json:"prev_chain_hash"`
|
||||
ChainHash string `json:"chain_hash"`
|
||||
AgentPubkeyKID string `json:"agent_pubkey_kid"`
|
||||
Signature string `json:"signature"`
|
||||
R2PayloadKey *string `json:"r2_payload_key"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
OperationID string `json:"operation_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
SeqNo int64 `json:"seq_no"`
|
||||
OperationType string `json:"operation_type"`
|
||||
IssuedAt int64 `json:"issued_at"`
|
||||
TTLMs int64 `json:"ttl_ms"`
|
||||
Nonce string `json:"nonce"`
|
||||
Subject string `json:"subject"`
|
||||
Action string `json:"action"`
|
||||
PayloadHash string `json:"payload_hash"`
|
||||
PrevChainHash string `json:"prev_chain_hash"`
|
||||
ChainHash string `json:"chain_hash"`
|
||||
AgentPubkeyKID string `json:"agent_pubkey_kid"`
|
||||
Signature string `json:"signature"`
|
||||
R2PayloadKey *string `json:"r2_payload_key"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type Receipt struct {
|
||||
@@ -161,36 +214,36 @@ type Export struct {
|
||||
|
||||
// EOR is the Elydora Operation Record — the fundamental unit of auditable activity.
|
||||
type EOR struct {
|
||||
OpVersion string `json:"op_version"`
|
||||
OperationID string `json:"operation_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
IssuedAt int64 `json:"issued_at"`
|
||||
TTLMs int64 `json:"ttl_ms"`
|
||||
Nonce string `json:"nonce"`
|
||||
OperationType string `json:"operation_type"`
|
||||
Subject interface{} `json:"subject"`
|
||||
Action interface{} `json:"action"`
|
||||
Payload interface{} `json:"payload"`
|
||||
PayloadHash string `json:"payload_hash"`
|
||||
PrevChainHash string `json:"prev_chain_hash"`
|
||||
AgentPubkeyKID string `json:"agent_pubkey_kid"`
|
||||
Signature string `json:"signature"`
|
||||
OpVersion string `json:"op_version"`
|
||||
OperationID string `json:"operation_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
IssuedAt int64 `json:"issued_at"`
|
||||
TTLMs int64 `json:"ttl_ms"`
|
||||
Nonce string `json:"nonce"`
|
||||
OperationType string `json:"operation_type"`
|
||||
Subject interface{} `json:"subject"`
|
||||
Action interface{} `json:"action"`
|
||||
Payload interface{} `json:"payload"`
|
||||
PayloadHash string `json:"payload_hash"`
|
||||
PrevChainHash string `json:"prev_chain_hash"`
|
||||
AgentPubkeyKID string `json:"agent_pubkey_kid"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// EAR is the Elydora Acknowledgment Receipt — server-issued receipt confirming acceptance.
|
||||
type EAR struct {
|
||||
ReceiptVersion string `json:"receipt_version"`
|
||||
ReceiptID string `json:"receipt_id"`
|
||||
OperationID string `json:"operation_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
ServerReceivedAt int64 `json:"server_received_at"`
|
||||
SeqNo int64 `json:"seq_no"`
|
||||
ChainHash string `json:"chain_hash"`
|
||||
QueueMessageID string `json:"queue_message_id"`
|
||||
ReceiptHash string `json:"receipt_hash"`
|
||||
ElydoraKID string `json:"elydora_kid"`
|
||||
ReceiptVersion string `json:"receipt_version"`
|
||||
ReceiptID string `json:"receipt_id"`
|
||||
OperationID string `json:"operation_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
ServerReceivedAt int64 `json:"server_received_at"`
|
||||
SeqNo int64 `json:"seq_no"`
|
||||
ChainHash string `json:"chain_hash"`
|
||||
QueueMessageID string `json:"queue_message_id"`
|
||||
ReceiptHash string `json:"receipt_hash"`
|
||||
ElydoraKID string `json:"elydora_kid"`
|
||||
ElydoraSignature string `json:"elydora_signature"`
|
||||
}
|
||||
|
||||
@@ -208,6 +261,7 @@ type RegisterAgentRequest struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
ResponsibleEntity string `json:"responsible_entity,omitempty"`
|
||||
IntegrationType IntegrationType `json:"integration_type"`
|
||||
Keys []RegisterAgentKeyInput `json:"keys"`
|
||||
}
|
||||
|
||||
@@ -326,7 +380,7 @@ type CreateExportResponse struct {
|
||||
}
|
||||
|
||||
type GetExportResponse struct {
|
||||
Export Export `json:"export"`
|
||||
Export Export `json:"export"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package elydora
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegisterAgentRequestIncludesIntegrationType(t *testing.T) {
|
||||
payload, err := json.Marshal(RegisterAgentRequest{
|
||||
AgentID: "agent-1",
|
||||
IntegrationType: IntegrationTypeSDK,
|
||||
Keys: []RegisterAgentKeyInput{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal register agent request: %v", err)
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal register agent request: %v", err)
|
||||
}
|
||||
if got := decoded["integration_type"]; got != "sdk" {
|
||||
t.Fatalf("integration_type = %v, want sdk", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationTypesMatchPublicAPIContract(t *testing.T) {
|
||||
got := []IntegrationType{
|
||||
IntegrationTypeAugment,
|
||||
IntegrationTypeClaudecode,
|
||||
IntegrationTypeCline,
|
||||
IntegrationTypeCodex,
|
||||
IntegrationTypeCopilot,
|
||||
IntegrationTypeCursor,
|
||||
IntegrationTypeDroid,
|
||||
IntegrationTypeGemini,
|
||||
IntegrationTypeGrok,
|
||||
IntegrationTypeKimi,
|
||||
IntegrationTypeKiroCLI,
|
||||
IntegrationTypeKiroIDE,
|
||||
IntegrationTypeLetta,
|
||||
IntegrationTypeOpenCode,
|
||||
IntegrationTypeQwen,
|
||||
IntegrationTypeEnterprise,
|
||||
IntegrationTypeGUI,
|
||||
IntegrationTypeSDK,
|
||||
IntegrationTypeOther,
|
||||
}
|
||||
want := []IntegrationType{
|
||||
"augment", "claudecode", "cline", "codex", "copilot", "cursor", "droid",
|
||||
"gemini", "grok", "kimi", "kirocli", "kiroide", "letta", "opencode", "qwen",
|
||||
"enterprise", "gui", "sdk", "other",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("integration types = %v, want %v", got, want)
|
||||
}
|
||||
for _, integrationType := range got {
|
||||
if !integrationType.IsValid() {
|
||||
t.Fatalf("integration type %q must be valid", integrationType)
|
||||
}
|
||||
}
|
||||
if IntegrationType("future-cli").IsValid() {
|
||||
t.Fatal("unknown integration type must be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterAgentRejectsInvalidIntegrationBeforeNetworkAccess(t *testing.T) {
|
||||
requestCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
requestCount++
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client := &Client{baseURL: server.URL, httpClient: server.Client()}
|
||||
requests := []*RegisterAgentRequest{
|
||||
nil,
|
||||
{AgentID: "agent-1"},
|
||||
{AgentID: "agent-1", IntegrationType: IntegrationType("future-cli")},
|
||||
}
|
||||
for _, request := range requests {
|
||||
if _, err := client.RegisterAgent(request); err == nil {
|
||||
t.Fatalf("RegisterAgent(%+v) succeeded, want validation error", request)
|
||||
}
|
||||
}
|
||||
if requestCount != 0 {
|
||||
t.Fatalf("network request count = %d, want 0", requestCount)
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,7 @@ const result = await client.verifyOperation(operationId);
|
||||
// Register a new agent
|
||||
const agent = await client.registerAgent({
|
||||
agent_id: 'my-agent',
|
||||
integration_type: 'codex',
|
||||
display_name: 'My Agent',
|
||||
responsible_entity: 'team@example.com',
|
||||
keys: [{ kid: 'key-v1', public_key: '<base64url>', algorithm: 'ed25519' }],
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
ErrorResponse,
|
||||
} from './types.js';
|
||||
import { ElydoraError } from './errors.js';
|
||||
import { assertIntegrationType } from './integration-types.js';
|
||||
import {
|
||||
jcsCanonicalise,
|
||||
computePayloadHash,
|
||||
@@ -148,6 +149,7 @@ export class ElydoraClient {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async registerAgent(request: RegisterAgentRequest): Promise<RegisterAgentResponse> {
|
||||
assertIntegrationType(request?.integration_type);
|
||||
return this.request<RegisterAgentResponse>('POST', '/v1/agents/register', request);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { ElydoraClient } from './client.js';
|
||||
export { ElydoraError } from './errors.js';
|
||||
export { INTEGRATION_TYPES } from './types.js';
|
||||
|
||||
export {
|
||||
jcsCanonicalise,
|
||||
@@ -24,6 +25,7 @@ export type {
|
||||
KeyStatus,
|
||||
ExportStatus,
|
||||
RbacRole,
|
||||
IntegrationType,
|
||||
ErrorCode,
|
||||
|
||||
// Entities
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { INTEGRATION_TYPES, type IntegrationType } from './types.js';
|
||||
|
||||
export function assertIntegrationType(value: unknown): asserts value is IntegrationType {
|
||||
const isSupported = typeof value === 'string'
|
||||
&& INTEGRATION_TYPES.some((integrationType) => integrationType === value);
|
||||
if (!isSupported) {
|
||||
throw new TypeError(
|
||||
`Invalid integration_type "${String(value)}". Expected one of: ${INTEGRATION_TYPES.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,28 @@ export type RbacRole =
|
||||
| 'compliance_auditor'
|
||||
| 'readonly_investigator'
|
||||
| 'integration_engineer';
|
||||
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];
|
||||
export type ErrorCode =
|
||||
| 'INVALID_SIGNATURE'
|
||||
| 'UNKNOWN_AGENT'
|
||||
@@ -36,6 +58,7 @@ export interface Agent {
|
||||
readonly org_id: string;
|
||||
readonly display_name: string;
|
||||
readonly responsible_entity: string;
|
||||
readonly integration_type: IntegrationType;
|
||||
readonly status: AgentStatus;
|
||||
readonly created_at: number;
|
||||
readonly updated_at: number;
|
||||
@@ -160,6 +183,7 @@ export interface EAR {
|
||||
|
||||
export interface RegisterAgentRequest {
|
||||
readonly agent_id: string;
|
||||
readonly integration_type: IntegrationType;
|
||||
readonly display_name?: string;
|
||||
readonly responsible_entity?: string;
|
||||
readonly keys: ReadonlyArray<{
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import test from 'node:test';
|
||||
|
||||
import { ElydoraClient, INTEGRATION_TYPES } from '../dist/index.js';
|
||||
|
||||
const EXPECTED_INTEGRATION_TYPES = [
|
||||
'augment',
|
||||
'claudecode',
|
||||
'cline',
|
||||
'codex',
|
||||
'copilot',
|
||||
'cursor',
|
||||
'droid',
|
||||
'gemini',
|
||||
'grok',
|
||||
'kimi',
|
||||
'kirocli',
|
||||
'kiroide',
|
||||
'letta',
|
||||
'opencode',
|
||||
'qwen',
|
||||
'enterprise',
|
||||
'gui',
|
||||
'sdk',
|
||||
'other',
|
||||
];
|
||||
|
||||
async function createFixture() {
|
||||
const requests = [];
|
||||
const server = http.createServer(async (request, response) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of request) chunks.push(chunk);
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
requests.push({
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
authorization: request.headers.authorization,
|
||||
body,
|
||||
});
|
||||
response.writeHead(201, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify({
|
||||
agent: {
|
||||
agent_id: body.agent_id,
|
||||
org_id: 'org-1',
|
||||
display_name: body.display_name,
|
||||
responsible_entity: body.responsible_entity,
|
||||
integration_type: body.integration_type,
|
||||
status: 'active',
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
keys: [],
|
||||
}));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
assert(address && typeof address === 'object');
|
||||
|
||||
const client = new ElydoraClient({
|
||||
orgId: 'org-1',
|
||||
agentId: 'admin-agent',
|
||||
privateKey: 'unused',
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
maxRetries: 0,
|
||||
});
|
||||
client.setToken('api-token');
|
||||
|
||||
return {
|
||||
client,
|
||||
requests,
|
||||
async close() {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('integration types match the public API contract', () => {
|
||||
assert.deepEqual(INTEGRATION_TYPES, EXPECTED_INTEGRATION_TYPES);
|
||||
});
|
||||
|
||||
test('agent registration sends integration_type in one authenticated request', async () => {
|
||||
const fixture = await createFixture();
|
||||
try {
|
||||
await assert.rejects(
|
||||
fixture.client.registerAgent({ agent_id: 'agent-1', keys: [] }),
|
||||
{
|
||||
name: 'TypeError',
|
||||
message: /Invalid integration_type "undefined"/,
|
||||
},
|
||||
);
|
||||
await assert.rejects(
|
||||
fixture.client.registerAgent({
|
||||
agent_id: 'agent-1',
|
||||
integration_type: 'future-cli',
|
||||
keys: [],
|
||||
}),
|
||||
{
|
||||
name: 'TypeError',
|
||||
message: /Invalid integration_type "future-cli"/,
|
||||
},
|
||||
);
|
||||
assert.equal(fixture.requests.length, 0);
|
||||
|
||||
const response = await fixture.client.registerAgent({
|
||||
agent_id: 'agent-1',
|
||||
integration_type: 'grok',
|
||||
display_name: 'Grok Agent',
|
||||
responsible_entity: 'platform@example.com',
|
||||
keys: [{ kid: 'key-v1', public_key: 'public-key', algorithm: 'ed25519' }],
|
||||
});
|
||||
|
||||
assert.equal(response.agent.integration_type, 'grok');
|
||||
assert.deepEqual(fixture.requests, [{
|
||||
method: 'POST',
|
||||
path: '/v1/agents/register',
|
||||
authorization: 'Bearer api-token',
|
||||
body: {
|
||||
agent_id: 'agent-1',
|
||||
integration_type: 'grok',
|
||||
display_name: 'Grok Agent',
|
||||
responsible_entity: 'platform@example.com',
|
||||
keys: [{ kid: 'key-v1', public_key: 'public-key', algorithm: 'ed25519' }],
|
||||
},
|
||||
}]);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
@@ -179,6 +179,7 @@ result = client.verify_operation(operation_id)
|
||||
# Register a new agent
|
||||
agent = client.register_agent({
|
||||
"agent_id": "my-agent",
|
||||
"integration_type": "codex",
|
||||
"display_name": "My Agent",
|
||||
"responsible_entity": "team@example.com",
|
||||
"keys": [{"kid": "key-v1", "public_key": "<base64url>", "algorithm": "ed25519"}],
|
||||
|
||||
@@ -44,6 +44,8 @@ from .types import (
|
||||
GetMeResponse,
|
||||
IssueTokenResponse,
|
||||
HealthResponse,
|
||||
INTEGRATION_TYPES,
|
||||
IntegrationType,
|
||||
)
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -93,4 +95,6 @@ __all__ = [
|
||||
"GetMeResponse",
|
||||
"IssueTokenResponse",
|
||||
"HealthResponse",
|
||||
"INTEGRATION_TYPES",
|
||||
"IntegrationType",
|
||||
]
|
||||
@@ -11,6 +11,7 @@ import aiohttp
|
||||
|
||||
from .crypto import compute_chain_hash, compute_payload_hash, sign_eor
|
||||
from .errors import ElydoraError
|
||||
from .integration_types import require_integration_type
|
||||
from .types import (
|
||||
AuditQueryResponse,
|
||||
AuthLoginResponse,
|
||||
@@ -231,6 +232,7 @@ class AsyncElydoraClient:
|
||||
|
||||
async def register_agent(self, request: RegisterAgentRequest) -> RegisterAgentResponse:
|
||||
"""Register a new agent with the organization."""
|
||||
require_integration_type(request.get("integration_type"))
|
||||
return await self._request("POST", "/v1/agents/register", json_body=request)
|
||||
|
||||
async def get_agent(self, agent_id: str) -> GetAgentResponse:
|
||||
|
||||
@@ -10,6 +10,7 @@ import requests
|
||||
|
||||
from .crypto import compute_chain_hash, compute_payload_hash, sign_eor
|
||||
from .errors import ElydoraError
|
||||
from .integration_types import require_integration_type
|
||||
from .types import (
|
||||
AuditQueryResponse,
|
||||
AuthLoginResponse,
|
||||
@@ -222,6 +223,7 @@ class ElydoraClient:
|
||||
|
||||
def register_agent(self, request: RegisterAgentRequest) -> RegisterAgentResponse:
|
||||
"""Register a new agent with the organization."""
|
||||
require_integration_type(request.get("integration_type"))
|
||||
return self._request("POST", "/v1/agents/register", json_body=request)
|
||||
|
||||
def get_agent(self, agent_id: str) -> GetAgentResponse:
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Runtime validation for agent integration types."""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from .types import INTEGRATION_TYPES, IntegrationType
|
||||
|
||||
|
||||
def require_integration_type(value: object) -> IntegrationType:
|
||||
if not isinstance(value, str) or value not in INTEGRATION_TYPES:
|
||||
expected = ", ".join(INTEGRATION_TYPES)
|
||||
raise ValueError(f"Invalid integration_type {value!r}. Expected one of: {expected}")
|
||||
return cast(IntegrationType, value)
|
||||
@@ -3,15 +3,13 @@
|
||||
All types use TypedDict for structural typing, compatible with Python 3.9+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Literal, TypedDict
|
||||
from typing import Literal, NotRequired, TypedDict
|
||||
else:
|
||||
from typing_extensions import Literal, TypedDict
|
||||
from typing_extensions import Literal, NotRequired, TypedDict
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -28,6 +26,32 @@ RbacRole = Literal[
|
||||
"readonly_investigator",
|
||||
"integration_engineer",
|
||||
]
|
||||
IntegrationType = Literal[
|
||||
"augment",
|
||||
"claudecode",
|
||||
"cline",
|
||||
"codex",
|
||||
"copilot",
|
||||
"cursor",
|
||||
"droid",
|
||||
"gemini",
|
||||
"grok",
|
||||
"kimi",
|
||||
"kirocli",
|
||||
"kiroide",
|
||||
"letta",
|
||||
"opencode",
|
||||
"qwen",
|
||||
"enterprise",
|
||||
"gui",
|
||||
"sdk",
|
||||
"other",
|
||||
]
|
||||
INTEGRATION_TYPES: Tuple[IntegrationType, ...] = (
|
||||
"augment", "claudecode", "cline", "codex", "copilot", "cursor", "droid",
|
||||
"gemini", "grok", "kimi", "kirocli", "kiroide", "letta", "opencode", "qwen",
|
||||
"enterprise", "gui", "sdk", "other",
|
||||
)
|
||||
ErrorCode = Literal[
|
||||
"INVALID_SIGNATURE",
|
||||
"UNKNOWN_AGENT",
|
||||
@@ -55,6 +79,7 @@ class Agent(TypedDict):
|
||||
org_id: str
|
||||
display_name: str
|
||||
responsible_entity: str
|
||||
integration_type: IntegrationType
|
||||
status: AgentStatus
|
||||
created_at: int
|
||||
updated_at: int
|
||||
@@ -187,11 +212,12 @@ class RegisterAgentKeyParam(TypedDict):
|
||||
algorithm: Literal["ed25519"]
|
||||
|
||||
|
||||
class RegisterAgentRequest(TypedDict, total=False):
|
||||
class RegisterAgentRequest(TypedDict):
|
||||
agent_id: str
|
||||
display_name: str
|
||||
responsible_entity: str
|
||||
integration_type: IntegrationType
|
||||
keys: List[RegisterAgentKeyParam]
|
||||
display_name: NotRequired[str]
|
||||
responsible_entity: NotRequired[str]
|
||||
|
||||
|
||||
class AuditQueryParams(TypedDict, total=False):
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import json
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import get_args
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora import (
|
||||
AsyncElydoraClient,
|
||||
ElydoraClient,
|
||||
INTEGRATION_TYPES,
|
||||
IntegrationType,
|
||||
RegisterAgentRequest,
|
||||
)
|
||||
|
||||
EXPECTED_INTEGRATION_TYPES = (
|
||||
"augment", "claudecode", "cline", "codex", "copilot", "cursor", "droid",
|
||||
"gemini", "grok", "kimi", "kirocli", "kiroide", "letta", "opencode", "qwen",
|
||||
"enterprise", "gui", "sdk", "other",
|
||||
)
|
||||
|
||||
|
||||
class RegistrationHandler(BaseHTTPRequestHandler):
|
||||
def do_POST(self) -> None:
|
||||
content_length = int(self.headers["Content-Length"])
|
||||
body = json.loads(self.rfile.read(content_length))
|
||||
self.server.requests.append({
|
||||
"method": self.command,
|
||||
"path": self.path,
|
||||
"authorization": self.headers.get("Authorization"),
|
||||
"body": body,
|
||||
})
|
||||
response = json.dumps({
|
||||
"agent": {
|
||||
"agent_id": body["agent_id"],
|
||||
"org_id": "org-1",
|
||||
"display_name": body.get("display_name", ""),
|
||||
"responsible_entity": body.get("responsible_entity", ""),
|
||||
"integration_type": body["integration_type"],
|
||||
"status": "active",
|
||||
"created_at": 1,
|
||||
"updated_at": 1,
|
||||
},
|
||||
"keys": [],
|
||||
}).encode()
|
||||
self.send_response(201)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(response)))
|
||||
self.end_headers()
|
||||
self.wfile.write(response)
|
||||
|
||||
def log_message(self, _format: str, *args: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registration_server():
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), RegistrationHandler)
|
||||
server.requests = []
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
|
||||
def registration_request():
|
||||
return {
|
||||
"agent_id": "agent-1",
|
||||
"integration_type": "grok",
|
||||
"display_name": "Grok Agent",
|
||||
"responsible_entity": "platform@example.com",
|
||||
"keys": [{"kid": "key-v1", "public_key": "public-key", "algorithm": "ed25519"}],
|
||||
}
|
||||
|
||||
|
||||
def expected_http_request():
|
||||
return {
|
||||
"method": "POST",
|
||||
"path": "/v1/agents/register",
|
||||
"authorization": "Bearer api-token",
|
||||
"body": registration_request(),
|
||||
}
|
||||
|
||||
|
||||
def test_integration_types_match_public_api_contract() -> None:
|
||||
assert INTEGRATION_TYPES == EXPECTED_INTEGRATION_TYPES
|
||||
assert get_args(IntegrationType) == EXPECTED_INTEGRATION_TYPES
|
||||
assert RegisterAgentRequest.__required_keys__ == frozenset({
|
||||
"agent_id", "integration_type", "keys",
|
||||
})
|
||||
assert RegisterAgentRequest.__optional_keys__ == frozenset({
|
||||
"display_name", "responsible_entity",
|
||||
})
|
||||
|
||||
|
||||
def test_sync_registration_requires_and_sends_integration_type(registration_server) -> None:
|
||||
host, port = registration_server.server_address
|
||||
client = ElydoraClient(
|
||||
"org-1", "admin-agent", "unused",
|
||||
base_url=f"http://{host}:{port}", max_retries=1, token="api-token",
|
||||
)
|
||||
with pytest.raises(ValueError, match="Invalid integration_type None"):
|
||||
client.register_agent({"agent_id": "agent-1", "keys": []})
|
||||
with pytest.raises(ValueError, match="Invalid integration_type 'future-cli'"):
|
||||
client.register_agent({
|
||||
"agent_id": "agent-1", "integration_type": "future-cli", "keys": [],
|
||||
})
|
||||
assert registration_server.requests == []
|
||||
|
||||
response = client.register_agent(registration_request())
|
||||
|
||||
assert response["agent"]["integration_type"] == "grok"
|
||||
assert registration_server.requests == [expected_http_request()]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_registration_requires_and_sends_integration_type(registration_server) -> None:
|
||||
host, port = registration_server.server_address
|
||||
client = AsyncElydoraClient(
|
||||
"org-1", "admin-agent", "unused",
|
||||
base_url=f"http://{host}:{port}", max_retries=1, token="api-token",
|
||||
)
|
||||
try:
|
||||
with pytest.raises(ValueError, match="Invalid integration_type None"):
|
||||
await client.register_agent({"agent_id": "agent-1", "keys": []})
|
||||
assert registration_server.requests == []
|
||||
|
||||
response = await client.register_agent(registration_request())
|
||||
|
||||
assert response["agent"]["integration_type"] == "grok"
|
||||
assert registration_server.requests == [expected_http_request()]
|
||||
finally:
|
||||
await client.close()
|
||||
Reference in new issue
Block a user