@electric-ax/agents-server 0.4.15 → 0.4.16

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