| 1 | import {
|
| 2 | createHash,
|
| 3 | createPublicKey,
|
| 4 | publicEncrypt,
|
| 5 | randomBytes,
|
| 6 | randomUUID,
|
| 7 | timingSafeEqual,
|
| 8 | } from 'node:crypto';
|
| 9 | import {
|
| 10 | ConnectorAgentProfileSchema,
|
| 11 | type ConnectorAgentProfile,
|
| 12 | } from '../../protocol/src/index.js';
|
| 13 | import {
|
| 14 | getOnboardingState,
|
| 15 | type AgentProfile,
|
| 16 | type OnboardingStore,
|
| 17 | type Project,
|
| 18 | } from './onboarding.js';
|
| 19 |
|
| 20 | export type ConnectorClaimStatus = 'pending' | 'approved' | 'rejected' | 'connected' | 'expired';
|
| 21 |
|
| 22 | export type ConnectorClaim = {
|
| 23 | claimId: string;
|
| 24 | secretHash: string;
|
| 25 | runtimeEndpoint: string;
|
| 26 | credentialPublicKey: string;
|
| 27 | profile: ConnectorAgentProfile;
|
| 28 | status: ConnectorClaimStatus;
|
| 29 | operatorId?: string;
|
| 30 | agentId?: string;
|
| 31 | credentialCiphertext?: string;
|
| 32 | createdAt: string;
|
| 33 | expiresAt: string;
|
| 34 | decidedAt?: string;
|
| 35 | connectedAt?: string;
|
| 36 | };
|
| 37 |
|
| 38 | export type PublicConnectorClaim = Omit<
|
| 39 | ConnectorClaim,
|
| 40 | 'secretHash' | 'credentialPublicKey' | 'credentialCiphertext' | 'operatorId'
|
| 41 | >;
|
| 42 |
|
| 43 | export function createConnectorClaimInput(input: {
|
| 44 | runtimeEndpoint: string;
|
| 45 | credentialPublicKey: string;
|
| 46 | profile: ConnectorAgentProfile;
|
| 47 | lifetimeMinutes?: number;
|
| 48 | }) {
|
| 49 | const key = createPublicKey(input.credentialPublicKey);
|
| 50 | if (key.asymmetricKeyType !== 'rsa') throw new Error('Connector key must be RSA');
|
| 51 | const details = key.asymmetricKeyDetails;
|
| 52 | if (details?.modulusLength && details.modulusLength < 2048)
|
| 53 | throw new Error('Connector RSA key must be at least 2048 bits');
|
| 54 | const claimSecret = `oc_cc_${randomBytes(32).toString('base64url')}`;
|
| 55 | const createdAt = new Date();
|
| 56 | const lifetimeMinutes = Math.max(5, Math.min(30, input.lifetimeMinutes ?? 15));
|
| 57 | const claim: ConnectorClaim = {
|
| 58 | claimId: randomUUID(),
|
| 59 | secretHash: hashConnectorClaimSecret(claimSecret),
|
| 60 | runtimeEndpoint: new URL(input.runtimeEndpoint).toString(),
|
| 61 | credentialPublicKey: input.credentialPublicKey,
|
| 62 | profile: ConnectorAgentProfileSchema.parse(input.profile),
|
| 63 | status: 'pending',
|
| 64 | createdAt: createdAt.toISOString(),
|
| 65 | expiresAt: new Date(createdAt.getTime() + lifetimeMinutes * 60_000).toISOString(),
|
| 66 | };
|
| 67 | return { claim, claimSecret };
|
| 68 | }
|
| 69 |
|
| 70 | export function hashConnectorClaimSecret(secret: string) {
|
| 71 | return createHash('sha256').update(secret).digest('base64url');
|
| 72 | }
|
| 73 |
|
| 74 | export function matchesConnectorClaimSecret(expectedHash: string, secret: string) {
|
| 75 | const expected = Buffer.from(expectedHash);
|
| 76 | const actual = Buffer.from(hashConnectorClaimSecret(secret));
|
| 77 | return expected.length === actual.length && timingSafeEqual(expected, actual);
|
| 78 | }
|
| 79 |
|
| 80 | export function connectorClaimExpired(claim: Pick<ConnectorClaim, 'expiresAt'>) {
|
| 81 | return Date.parse(claim.expiresAt) <= Date.now();
|
| 82 | }
|
| 83 |
|
| 84 | export function publicConnectorClaim(claim: ConnectorClaim): PublicConnectorClaim {
|
| 85 | const safe = { ...claim } as Partial<ConnectorClaim>;
|
| 86 | delete safe.secretHash;
|
| 87 | delete safe.credentialPublicKey;
|
| 88 | delete safe.credentialCiphertext;
|
| 89 | delete safe.operatorId;
|
| 90 | return safe as PublicConnectorClaim;
|
| 91 | }
|
| 92 |
|
| 93 | export function encryptConnectorCredential(publicKey: string, token: string) {
|
| 94 | return publicEncrypt(
|
| 95 | { key: createPublicKey(publicKey), oaepHash: 'sha256' },
|
| 96 | Buffer.from(token),
|
| 97 | ).toString('base64url');
|
| 98 | }
|
| 99 |
|
| 100 | export async function createConnectorAgent(
|
| 101 | store: OnboardingStore,
|
| 102 | operatorId: string,
|
| 103 | name: string,
|
| 104 | profile: ConnectorAgentProfile,
|
| 105 | ) {
|
| 106 | const agentName = name.trim();
|
| 107 | if (!agentName) throw new Error('Agent name is required');
|
| 108 | const now = new Date().toISOString();
|
| 109 | const state = await getOnboardingState(store, operatorId);
|
| 110 | let project = state.projects.find((item) => item.name === 'Connected agents');
|
| 111 | if (!project) {
|
| 112 | project = {
|
| 113 | projectId: `project_${randomUUID()}`,
|
| 114 | name: 'Connected agents',
|
| 115 | createdAt: now,
|
| 116 | } satisfies Project;
|
| 117 | await store.upsert(operatorId, 'project', project.projectId, project);
|
| 118 | }
|
| 119 | const agent: AgentProfile = {
|
| 120 | agentId: `agent_${randomUUID()}`,
|
| 121 | projectId: project.projectId,
|
| 122 | name: agentName,
|
| 123 | description: profile.description,
|
| 124 | framework: profile.framework,
|
| 125 | agentVersion: profile.agentVersion,
|
| 126 | ...(profile.modelProvider ? { modelProvider: profile.modelProvider } : {}),
|
| 127 | ...(profile.modelName ? { modelName: profile.modelName } : {}),
|
| 128 | nameProvenance: 'operator_attested',
|
| 129 | profileProvenance: 'self_declared',
|
| 130 | agentMode: 'persistent_runtime',
|
| 131 | transport: 'direct_a2a',
|
| 132 | autoPublish: false,
|
| 133 | autoAcceptPolicy: 'off',
|
| 134 | autoAcceptTaskCategories: [],
|
| 135 | capabilities: [...new Set(profile.capabilities)],
|
| 136 | limitations: [...new Set(profile.limitations)],
|
| 137 | identityMode: 'connector_claim',
|
| 138 | status: 'active',
|
| 139 | createdAt: now,
|
| 140 | updatedAt: now,
|
| 141 | };
|
| 142 | await store.upsert(operatorId, 'agent_profile', agent.agentId, agent, {
|
| 143 | provenance: 'operator_attested',
|
| 144 | schemaName: 'openclasp.agent_profile',
|
| 145 | schemaVersion: '0.1',
|
| 146 | });
|
| 147 | return { project, agent };
|
| 148 | }
|