@electric-ax/agents-server 0.4.15 → 0.4.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,7 @@ import {
16
16
  ErrCodeNotFound,
17
17
  ErrCodeUnknownEntityType,
18
18
  ErrCodeInvalidRequest,
19
+ ErrCodeUnauthorized,
19
20
  toPublicEntity,
20
21
  } from '../electric-agents-types.js'
21
22
  import {
@@ -27,8 +28,19 @@ import {
27
28
  unlinkEntityDispatchSubscription,
28
29
  } from './dispatch-policy.js'
29
30
  import { ElectricAgentsError } from '../entity-manager.js'
31
+ import {
32
+ canAccessEntity,
33
+ canAccessEntityType,
34
+ isPermissionBypassPrincipal,
35
+ principalSubject,
36
+ } from '../permissions.js'
30
37
  import { routeBody, withSchema } from './schema.js'
31
- import type { ElectricAgentsEntity } from '../electric-agents-types.js'
38
+ import type {
39
+ ElectricAgentsEntity,
40
+ ElectricAgentsEntityType,
41
+ EntityPermission,
42
+ SendRequest,
43
+ } from '../electric-agents-types.js'
32
44
  import type { JsonRouteRequest } from './schema.js'
33
45
  import type { RouterType } from 'itty-router'
34
46
  import type { TenantContext } from './context.js'
@@ -36,9 +48,11 @@ import type { EventSourceSubscriptionInput } from '@electric-ax/agents-runtime'
36
48
 
37
49
  interface AgentsRouteRequest extends JsonRouteRequest {
38
50
  entityRoute?: ExistingEntityRoute
51
+ spawnRoute?: SpawnableEntityRoute
39
52
  }
40
53
 
41
54
  type ExistingEntityRoute = { entityUrl: string; entity: ElectricAgentsEntity }
55
+ type SpawnableEntityRoute = { entityType: ElectricAgentsEntityType }
42
56
  type AgentsRouteArgs = [TenantContext]
43
57
  type AgentsRouteResult = Response | undefined
44
58
 
@@ -78,6 +92,41 @@ const wakeConditionSchema = Type.Union([
78
92
  }),
79
93
  ])
80
94
 
95
+ const permissionSubjectSchema = Type.Object(
96
+ {
97
+ subject_kind: Type.Union([
98
+ Type.Literal(`principal`),
99
+ Type.Literal(`principal_kind`),
100
+ ]),
101
+ subject_value: Type.String(),
102
+ },
103
+ { additionalProperties: false }
104
+ )
105
+
106
+ const entityPermissionSchema = Type.Union([
107
+ Type.Literal(`read`),
108
+ Type.Literal(`write`),
109
+ Type.Literal(`delete`),
110
+ Type.Literal(`signal`),
111
+ Type.Literal(`fork`),
112
+ Type.Literal(`schedule`),
113
+ Type.Literal(`spawn`),
114
+ Type.Literal(`manage`),
115
+ ])
116
+
117
+ const entityPermissionGrantInputSchema = Type.Object(
118
+ {
119
+ ...permissionSubjectSchema.properties,
120
+ permission: entityPermissionSchema,
121
+ propagation: Type.Optional(
122
+ Type.Union([Type.Literal(`self`), Type.Literal(`descendants`)])
123
+ ),
124
+ copy_to_children: Type.Optional(Type.Boolean()),
125
+ expires_at: Type.Optional(Type.String()),
126
+ },
127
+ { additionalProperties: false }
128
+ )
129
+
81
130
  const spawnBodySchema = Type.Object({
82
131
  args: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
83
132
  tags: Type.Optional(stringRecordSchema),
@@ -85,6 +134,8 @@ const spawnBodySchema = Type.Object({
85
134
  dispatch_policy: Type.Optional(dispatchPolicySchema),
86
135
  sandbox: Type.Optional(sandboxChoiceSchema),
87
136
  initialMessage: Type.Optional(Type.Unknown()),
137
+ grants: Type.Optional(Type.Array(entityPermissionGrantInputSchema)),
138
+ initialMessageType: Type.Optional(Type.String()),
88
139
  wake: Type.Optional(
89
140
  Type.Object({
90
141
  subscriberUrl: Type.String(),
@@ -112,8 +163,30 @@ const sendBodySchema = Type.Object({
112
163
  position: Type.Optional(Type.String()),
113
164
  afterMs: Type.Optional(Type.Number()),
114
165
  from: Type.Optional(Type.String()),
166
+ from_principal: Type.Optional(Type.String()),
167
+ from_agent: Type.Optional(Type.String()),
115
168
  })
116
169
 
170
+ function agentUrlForPrincipal(principal: {
171
+ kind: string
172
+ id: string
173
+ key: string
174
+ }): string | null {
175
+ if (principal.kind === `agent`) return `/${principal.id}`
176
+ if (principal.key.startsWith(`entity:`)) {
177
+ return `/${principal.key.slice(`entity:`.length)}`
178
+ }
179
+ return null
180
+ }
181
+
182
+ function agentUrlPath(value: string): string {
183
+ try {
184
+ return new URL(value).pathname
185
+ } catch {
186
+ return value
187
+ }
188
+ }
189
+
117
190
  const inboxMessageBodySchema = Type.Object({
118
191
  payload: Type.Optional(Type.Unknown()),
119
192
  position: Type.Optional(Type.String()),
@@ -215,6 +288,9 @@ type ScheduleBody = Static<typeof scheduleBodySchema>
215
288
  type EventSourceSubscriptionBody = Static<
216
289
  typeof eventSourceSubscriptionBodySchema
217
290
  >
291
+ type EntityPermissionGrantInput = Static<
292
+ typeof entityPermissionGrantInputSchema
293
+ >
218
294
  type AttachmentSubjectType = `inbox` | `run` | `text` | `tool_call` | `context`
219
295
  type AttachmentRole = `input` | `output`
220
296
  type ParsedAttachmentForm = {
@@ -248,88 +324,137 @@ entitiesRouter.put(
248
324
  `/:type/:instanceId`,
249
325
  withSpawnableEntityType,
250
326
  withSchema(spawnBodySchema),
327
+ withSpawnPermission,
251
328
  spawnEntity
252
329
  )
253
- entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity)
254
- entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity)
255
- entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity)
330
+ entitiesRouter.get(
331
+ `/:type/:instanceId`,
332
+ withExistingEntity,
333
+ withEntityPermission(`read`),
334
+ getEntity
335
+ )
336
+ entitiesRouter.head(
337
+ `/:type/:instanceId`,
338
+ withExistingEntity,
339
+ withEntityPermission(`read`),
340
+ headEntity
341
+ )
342
+ entitiesRouter.delete(
343
+ `/:type/:instanceId`,
344
+ withExistingEntity,
345
+ withEntityPermission(`delete`),
346
+ killEntity
347
+ )
256
348
  entitiesRouter.post(
257
349
  `/:type/:instanceId/signal`,
258
350
  withExistingEntity,
259
351
  withSchema(signalBodySchema),
352
+ withEntityPermission(`signal`),
260
353
  signalEntity
261
354
  )
262
355
  entitiesRouter.post(
263
356
  `/:type/:instanceId/send`,
264
357
  withExistingEntity,
265
358
  withSchema(sendBodySchema),
359
+ withEntityPermission(`write`),
266
360
  sendEntity
267
361
  )
268
362
  entitiesRouter.post(
269
363
  `/:type/:instanceId/attachments`,
270
364
  withExistingEntity,
365
+ withEntityPermission(`write`),
271
366
  createAttachment
272
367
  )
273
368
  entitiesRouter.get(
274
369
  `/:type/:instanceId/attachments/:attachmentId`,
275
370
  withExistingEntity,
371
+ withEntityPermission(`read`),
276
372
  readAttachment
277
373
  )
278
374
  entitiesRouter.delete(
279
375
  `/:type/:instanceId/attachments/:attachmentId`,
280
376
  withExistingEntity,
377
+ withEntityPermission(`write`),
281
378
  deleteAttachment
282
379
  )
283
380
  entitiesRouter.patch(
284
381
  `/:type/:instanceId/inbox/:messageKey`,
285
382
  withExistingEntity,
286
383
  withSchema(inboxMessageBodySchema),
384
+ withEntityPermission(`write`),
287
385
  updateInboxMessage
288
386
  )
289
387
  entitiesRouter.delete(
290
388
  `/:type/:instanceId/inbox/:messageKey`,
291
389
  withExistingEntity,
390
+ withEntityPermission(`write`),
292
391
  deleteInboxMessage
293
392
  )
294
393
  entitiesRouter.post(
295
394
  `/:type/:instanceId/fork`,
296
395
  withExistingEntity,
297
396
  withSchema(forkBodySchema),
397
+ withEntityPermission(`fork`),
298
398
  forkEntity
299
399
  )
300
400
  entitiesRouter.post(
301
401
  `/:type/:instanceId/tags/:tagKey`,
302
402
  withExistingEntity,
303
403
  withSchema(setTagBodySchema),
404
+ withEntityPermission(`write`),
304
405
  setTag
305
406
  )
306
407
  entitiesRouter.delete(
307
408
  `/:type/:instanceId/tags/:tagKey`,
308
409
  withExistingEntity,
410
+ withEntityPermission(`write`),
309
411
  deleteTag
310
412
  )
311
413
  entitiesRouter.put(
312
414
  `/:type/:instanceId/schedules/:scheduleId`,
313
415
  withExistingEntity,
314
416
  withSchema(scheduleBodySchema),
417
+ withEntityPermission(`schedule`),
315
418
  upsertSchedule
316
419
  )
317
420
  entitiesRouter.delete(
318
421
  `/:type/:instanceId/schedules/:scheduleId`,
319
422
  withExistingEntity,
423
+ withEntityPermission(`schedule`),
320
424
  deleteSchedule
321
425
  )
322
426
  entitiesRouter.put(
323
427
  `/:type/:instanceId/event-source-subscriptions/:subscriptionId`,
324
428
  withExistingEntity,
325
429
  withSchema(eventSourceSubscriptionBodySchema),
430
+ withEntityPermission(`write`),
326
431
  upsertEventSourceSubscription
327
432
  )
328
433
  entitiesRouter.delete(
329
434
  `/:type/:instanceId/event-source-subscriptions/:subscriptionId`,
330
435
  withExistingEntity,
436
+ withEntityPermission(`write`),
331
437
  deleteEventSourceSubscription
332
438
  )
439
+ entitiesRouter.get(
440
+ `/:type/:instanceId/grants`,
441
+ withExistingEntity,
442
+ withEntityPermission(`manage`),
443
+ listEntityPermissionGrants
444
+ )
445
+ entitiesRouter.post(
446
+ `/:type/:instanceId/grants`,
447
+ withExistingEntity,
448
+ withSchema(entityPermissionGrantInputSchema),
449
+ withEntityPermission(`manage`),
450
+ createEntityPermissionGrant
451
+ )
452
+ entitiesRouter.delete(
453
+ `/:type/:instanceId/grants/:grantId`,
454
+ withExistingEntity,
455
+ withEntityPermission(`manage`),
456
+ deleteEntityPermissionGrant
457
+ )
333
458
 
334
459
  function entityUrlFromSegments(
335
460
  type: string,
@@ -503,6 +628,31 @@ function rejectPrincipalEntityMutation(
503
628
  )
504
629
  }
505
630
 
631
+ function parseExpiresAt(value: string | undefined): Date | undefined {
632
+ if (value === undefined) return undefined
633
+ const expiresAt = new Date(value)
634
+ if (Number.isNaN(expiresAt.getTime())) {
635
+ throw new ElectricAgentsError(
636
+ ErrCodeInvalidRequest,
637
+ `Invalid expires_at timestamp`,
638
+ 400
639
+ )
640
+ }
641
+ return expiresAt
642
+ }
643
+
644
+ function parseGrantId(request: AgentsRouteRequest): number {
645
+ const grantId = Number.parseInt(String(request.params.grantId), 10)
646
+ if (!Number.isSafeInteger(grantId) || grantId <= 0) {
647
+ throw new ElectricAgentsError(
648
+ ErrCodeInvalidRequest,
649
+ `Invalid grant id`,
650
+ 400
651
+ )
652
+ }
653
+ return grantId
654
+ }
655
+
506
656
  async function withExistingEntity(
507
657
  request: AgentsRouteRequest,
508
658
  ctx: TenantContext
@@ -574,9 +724,96 @@ async function withSpawnableEntityType(
574
724
  )
575
725
  }
576
726
 
727
+ request.spawnRoute = { entityType }
577
728
  return undefined
578
729
  }
579
730
 
731
+ function withEntityPermission(permission: EntityPermission) {
732
+ return async (
733
+ request: AgentsRouteRequest,
734
+ ctx: TenantContext
735
+ ): Promise<AgentsRouteResult> => {
736
+ const { entity } = requireExistingEntityRoute(request)
737
+ if (await canAccessEntity(ctx, entity, permission, request as Request)) {
738
+ return undefined
739
+ }
740
+ return apiError(
741
+ 401,
742
+ ErrCodeUnauthorized,
743
+ `Principal is not allowed to ${permission} ${entity.url}`
744
+ )
745
+ }
746
+ }
747
+
748
+ async function withSpawnPermission(
749
+ request: AgentsRouteRequest,
750
+ ctx: TenantContext
751
+ ): Promise<AgentsRouteResult> {
752
+ const parsed = routeBody<SpawnBody>(request)
753
+ const entityType = request.spawnRoute?.entityType
754
+ if (!entityType) {
755
+ throw new Error(`spawnable entity type middleware did not run`)
756
+ }
757
+
758
+ if (
759
+ !(await canAccessEntityType(ctx, entityType, `spawn`, request as Request))
760
+ ) {
761
+ return apiError(
762
+ 401,
763
+ ErrCodeUnauthorized,
764
+ `Principal is not allowed to spawn ${entityType.name}`
765
+ )
766
+ }
767
+
768
+ if (!parsed.parent) return undefined
769
+
770
+ const parent = await ctx.entityManager.registry.getEntity(parsed.parent)
771
+ if (!parent) {
772
+ return apiError(404, ErrCodeNotFound, `Parent entity not found`)
773
+ }
774
+ if (await canAccessEntity(ctx, parent, `spawn`, request as Request)) {
775
+ return await validateParentedSpawnGrants(request, ctx, parent, parsed)
776
+ }
777
+ return apiError(
778
+ 401,
779
+ ErrCodeUnauthorized,
780
+ `Principal is not allowed to spawn children from ${parent.url}`
781
+ )
782
+ }
783
+
784
+ async function validateParentedSpawnGrants(
785
+ request: AgentsRouteRequest,
786
+ ctx: TenantContext,
787
+ parent: ElectricAgentsEntity,
788
+ parsed: SpawnBody
789
+ ): Promise<AgentsRouteResult> {
790
+ const needsParentManage = (parsed.grants ?? []).some(
791
+ requiresParentManageForInitialGrant
792
+ )
793
+ if (!needsParentManage) return undefined
794
+
795
+ if (await canAccessEntity(ctx, parent, `manage`, request as Request)) {
796
+ return undefined
797
+ }
798
+
799
+ return apiError(
800
+ 401,
801
+ ErrCodeUnauthorized,
802
+ `Principal is not allowed to delegate broad grants from ${parent.url}`
803
+ )
804
+ }
805
+
806
+ function requiresParentManageForInitialGrant(
807
+ grant: EntityPermissionGrantInput
808
+ ): boolean {
809
+ return (
810
+ grant.permission === `manage` ||
811
+ grant.subject_kind === `principal_kind` ||
812
+ grant.propagation === `descendants` ||
813
+ grant.copy_to_children === true
814
+ )
815
+ }
816
+
580
817
  async function listEntities(
581
818
  { query }: AgentsRouteRequest,
582
819
  ctx: TenantContext
@@ -586,10 +823,61 @@ async function listEntities(
586
823
  status: firstQueryValue(query.status),
587
824
  parent: firstQueryValue(query.parent),
588
825
  created_by: firstQueryValue(query.created_by),
826
+ readableBy: {
827
+ ...principalSubject(ctx.principal),
828
+ bypass: isPermissionBypassPrincipal(ctx),
829
+ },
589
830
  })
590
831
  return json(entities.map((entity) => toPublicEntity(entity)))
591
832
  }
592
833
 
834
+ async function listEntityPermissionGrants(
835
+ request: AgentsRouteRequest,
836
+ ctx: TenantContext
837
+ ): Promise<Response> {
838
+ const { entityUrl } = requireExistingEntityRoute(request)
839
+ const grants =
840
+ await ctx.entityManager.registry.listEntityPermissionGrants(entityUrl)
841
+ return json({ grants })
842
+ }
843
+
844
+ async function createEntityPermissionGrant(
845
+ request: AgentsRouteRequest,
846
+ ctx: TenantContext
847
+ ): Promise<Response> {
848
+ const { entityUrl } = requireExistingEntityRoute(request)
849
+ const parsed = routeBody<EntityPermissionGrantInput>(request)
850
+ const grant = await ctx.entityManager.registry.createEntityPermissionGrant({
851
+ entityUrl,
852
+ permission: parsed.permission,
853
+ subjectKind: parsed.subject_kind,
854
+ subjectValue: parsed.subject_value,
855
+ propagation: parsed.propagation,
856
+ copyToChildren: parsed.copy_to_children,
857
+ expiresAt: parseExpiresAt(parsed.expires_at),
858
+ createdBy: ctx.principal.url,
859
+ })
860
+ await ctx.entityBridgeManager.onEntityChanged(entityUrl)
861
+ return json(grant, { status: 201 })
862
+ }
863
+
864
+ async function deleteEntityPermissionGrant(
865
+ request: AgentsRouteRequest,
866
+ ctx: TenantContext
867
+ ): Promise<Response> {
868
+ const { entityUrl } = requireExistingEntityRoute(request)
869
+ const deleted = await ctx.entityManager.registry.deleteEntityPermissionGrant(
870
+ entityUrl,
871
+ parseGrantId(request)
872
+ )
873
+ if (deleted) {
874
+ await ctx.entityBridgeManager.onEntityChanged(entityUrl)
875
+ }
876
+ return deleted
877
+ ? status(204)
878
+ : apiError(404, ErrCodeNotFound, `Grant not found`)
879
+ }
880
+
593
881
  async function upsertSchedule(
594
882
  request: AgentsRouteRequest,
595
883
  ctx: TenantContext
@@ -805,6 +1093,7 @@ async function forkEntity(
805
1093
  const result = await ctx.entityManager.forkSubtree(entityUrl, {
806
1094
  rootInstanceId: parsed.instance_id,
807
1095
  waitTimeoutMs: parsed.waitTimeoutMs,
1096
+ createdBy: ctx.principal.url,
808
1097
  ...(parsed.fork_pointer && {
809
1098
  forkPointer: {
810
1099
  offset: parsed.fork_pointer.offset,
@@ -837,6 +1126,26 @@ async function sendEntity(
837
1126
  `Request from must match Electric-Principal`
838
1127
  )
839
1128
  }
1129
+ if (
1130
+ parsed.from_principal !== undefined &&
1131
+ parsed.from_principal !== principal.url
1132
+ ) {
1133
+ return apiError(
1134
+ 400,
1135
+ ErrCodeInvalidRequest,
1136
+ `Request from_principal must match Electric-Principal`
1137
+ )
1138
+ }
1139
+ if (parsed.from_agent !== undefined) {
1140
+ const principalAgentUrl = agentUrlForPrincipal(principal)
1141
+ if (agentUrlPath(parsed.from_agent) !== principalAgentUrl) {
1142
+ return apiError(
1143
+ 400,
1144
+ ErrCodeInvalidRequest,
1145
+ `Request from_agent must match authenticated agent principal`
1146
+ )
1147
+ }
1148
+ }
840
1149
  await ctx.entityManager.ensurePrincipal(principal)
841
1150
  const { entityUrl, entity } = requireExistingEntityRoute(request)
842
1151
 
@@ -845,28 +1154,25 @@ async function sendEntity(
845
1154
  : await backfillEntityDispatchPolicy(ctx, entity)
846
1155
  await linkEntityDispatchSubscription(ctx, dispatchEntity)
847
1156
 
1157
+ const sendReq: SendRequest = {
1158
+ from: principal.url,
1159
+ from_principal: principal.url,
1160
+ from_agent: parsed.from_agent,
1161
+ payload: parsed.payload,
1162
+ key: parsed.key,
1163
+ type: parsed.type,
1164
+ mode: parsed.mode,
1165
+ position: parsed.position,
1166
+ }
1167
+
848
1168
  if (parsed.afterMs && parsed.afterMs > 0) {
849
1169
  await ctx.entityManager.enqueueDelayedSend(
850
1170
  entityUrl,
851
- {
852
- from: principal.url,
853
- payload: parsed.payload,
854
- key: parsed.key,
855
- type: parsed.type,
856
- mode: parsed.mode,
857
- position: parsed.position,
858
- },
1171
+ sendReq,
859
1172
  new Date(Date.now() + parsed.afterMs)
860
1173
  )
861
1174
  } else {
862
- await ctx.entityManager.send(entityUrl, {
863
- from: principal.url,
864
- payload: parsed.payload,
865
- key: parsed.key,
866
- type: parsed.type,
867
- mode: parsed.mode,
868
- position: parsed.position,
869
- })
1175
+ await ctx.entityManager.send(entityUrl, sendReq)
870
1176
  }
871
1177
 
872
1178
  return status(204)
@@ -988,9 +1294,29 @@ async function spawnEntity(
988
1294
  dispatch_policy: dispatchPolicy,
989
1295
  sandbox: parsed.sandbox,
990
1296
  initialMessage: undefined,
1297
+ initialMessageType: undefined,
991
1298
  wake: parsed.wake,
992
1299
  created_by: principal.url,
993
1300
  })
1301
+ if (parsed.parent) {
1302
+ await ctx.entityManager.registry.copyEntityPermissionGrantsForSpawn(
1303
+ parsed.parent,
1304
+ entity.url,
1305
+ principal.url
1306
+ )
1307
+ }
1308
+ for (const grant of parsed.grants ?? []) {
1309
+ await ctx.entityManager.registry.createEntityPermissionGrant({
1310
+ entityUrl: entity.url,
1311
+ permission: grant.permission,
1312
+ subjectKind: grant.subject_kind,
1313
+ subjectValue: grant.subject_value,
1314
+ propagation: grant.propagation,
1315
+ copyToChildren: grant.copy_to_children,
1316
+ expiresAt: parseExpiresAt(grant.expires_at),
1317
+ createdBy: principal.url,
1318
+ })
1319
+ }
994
1320
  const linkBeforeInitialMessage =
995
1321
  parsed.initialMessage !== undefined &&
996
1322
  shouldLinkDispatchBeforeInitialMessage(dispatchPolicy)
@@ -1001,6 +1327,7 @@ async function spawnEntity(
1001
1327
  await ctx.entityManager.send(entity.url, {
1002
1328
  from: principal.url,
1003
1329
  payload: parsed.initialMessage,
1330
+ type: parsed.initialMessageType,
1004
1331
  })
1005
1332
  }
1006
1333
  if (!linkBeforeInitialMessage) {