@electric-ax/agents-server 0.4.14 → 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.
@@ -11,10 +11,12 @@ import { Router, json, status } from 'itty-router'
11
11
  import { apiError } from '../electric-agents-http.js'
12
12
  import { parsePrincipalKey, principalUrl } from '../principal.js'
13
13
  import { dispatchPolicySchema } from '../dispatch-policy-schema.js'
14
+ import { sandboxChoiceSchema } from '../sandbox-choice-schema.js'
14
15
  import {
15
16
  ErrCodeNotFound,
16
17
  ErrCodeUnknownEntityType,
17
18
  ErrCodeInvalidRequest,
19
+ ErrCodeUnauthorized,
18
20
  toPublicEntity,
19
21
  } from '../electric-agents-types.js'
20
22
  import {
@@ -26,8 +28,19 @@ import {
26
28
  unlinkEntityDispatchSubscription,
27
29
  } from './dispatch-policy.js'
28
30
  import { ElectricAgentsError } from '../entity-manager.js'
31
+ import {
32
+ canAccessEntity,
33
+ canAccessEntityType,
34
+ isPermissionBypassPrincipal,
35
+ principalSubject,
36
+ } from '../permissions.js'
29
37
  import { routeBody, withSchema } from './schema.js'
30
- 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'
31
44
  import type { JsonRouteRequest } from './schema.js'
32
45
  import type { RouterType } from 'itty-router'
33
46
  import type { TenantContext } from './context.js'
@@ -35,9 +48,11 @@ import type { EventSourceSubscriptionInput } from '@electric-ax/agents-runtime'
35
48
 
36
49
  interface AgentsRouteRequest extends JsonRouteRequest {
37
50
  entityRoute?: ExistingEntityRoute
51
+ spawnRoute?: SpawnableEntityRoute
38
52
  }
39
53
 
40
54
  type ExistingEntityRoute = { entityUrl: string; entity: ElectricAgentsEntity }
55
+ type SpawnableEntityRoute = { entityType: ElectricAgentsEntityType }
41
56
  type AgentsRouteArgs = [TenantContext]
42
57
  type AgentsRouteResult = Response | undefined
43
58
 
@@ -77,12 +92,49 @@ const wakeConditionSchema = Type.Union([
77
92
  }),
78
93
  ])
79
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
+
80
130
  const spawnBodySchema = Type.Object({
81
131
  args: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
82
132
  tags: Type.Optional(stringRecordSchema),
83
133
  parent: Type.Optional(Type.String()),
84
134
  dispatch_policy: Type.Optional(dispatchPolicySchema),
135
+ sandbox: Type.Optional(sandboxChoiceSchema),
85
136
  initialMessage: Type.Optional(Type.Unknown()),
137
+ grants: Type.Optional(Type.Array(entityPermissionGrantInputSchema)),
86
138
  wake: Type.Optional(
87
139
  Type.Object({
88
140
  subscriberUrl: Type.String(),
@@ -110,8 +162,30 @@ const sendBodySchema = Type.Object({
110
162
  position: Type.Optional(Type.String()),
111
163
  afterMs: Type.Optional(Type.Number()),
112
164
  from: Type.Optional(Type.String()),
165
+ from_principal: Type.Optional(Type.String()),
166
+ from_agent: Type.Optional(Type.String()),
113
167
  })
114
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
+
115
189
  const inboxMessageBodySchema = Type.Object({
116
190
  payload: Type.Optional(Type.Unknown()),
117
191
  position: Type.Optional(Type.String()),
@@ -135,6 +209,15 @@ const inboxMessageBodySchema = Type.Object({
135
209
  const forkBodySchema = Type.Object({
136
210
  instance_id: Type.Optional(Type.String()),
137
211
  waitTimeoutMs: Type.Optional(Type.Number()),
212
+ // Optional anchor pointing at an event on the source root's `main`
213
+ // stream. Wire shape is snake_case; the route handler translates to
214
+ // camelCase before forwarding to entity-manager.
215
+ fork_pointer: Type.Optional(
216
+ Type.Object({
217
+ offset: Type.Union([Type.String(), Type.Null()]),
218
+ sub_offset: Type.Number(),
219
+ })
220
+ ),
138
221
  })
139
222
 
140
223
  const setTagBodySchema = Type.Object({
@@ -204,6 +287,9 @@ type ScheduleBody = Static<typeof scheduleBodySchema>
204
287
  type EventSourceSubscriptionBody = Static<
205
288
  typeof eventSourceSubscriptionBodySchema
206
289
  >
290
+ type EntityPermissionGrantInput = Static<
291
+ typeof entityPermissionGrantInputSchema
292
+ >
207
293
  type AttachmentSubjectType = `inbox` | `run` | `text` | `tool_call` | `context`
208
294
  type AttachmentRole = `input` | `output`
209
295
  type ParsedAttachmentForm = {
@@ -237,88 +323,137 @@ entitiesRouter.put(
237
323
  `/:type/:instanceId`,
238
324
  withSpawnableEntityType,
239
325
  withSchema(spawnBodySchema),
326
+ withSpawnPermission,
240
327
  spawnEntity
241
328
  )
242
- entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity)
243
- entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity)
244
- 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
+ )
245
347
  entitiesRouter.post(
246
348
  `/:type/:instanceId/signal`,
247
349
  withExistingEntity,
248
350
  withSchema(signalBodySchema),
351
+ withEntityPermission(`signal`),
249
352
  signalEntity
250
353
  )
251
354
  entitiesRouter.post(
252
355
  `/:type/:instanceId/send`,
253
356
  withExistingEntity,
254
357
  withSchema(sendBodySchema),
358
+ withEntityPermission(`write`),
255
359
  sendEntity
256
360
  )
257
361
  entitiesRouter.post(
258
362
  `/:type/:instanceId/attachments`,
259
363
  withExistingEntity,
364
+ withEntityPermission(`write`),
260
365
  createAttachment
261
366
  )
262
367
  entitiesRouter.get(
263
368
  `/:type/:instanceId/attachments/:attachmentId`,
264
369
  withExistingEntity,
370
+ withEntityPermission(`read`),
265
371
  readAttachment
266
372
  )
267
373
  entitiesRouter.delete(
268
374
  `/:type/:instanceId/attachments/:attachmentId`,
269
375
  withExistingEntity,
376
+ withEntityPermission(`write`),
270
377
  deleteAttachment
271
378
  )
272
379
  entitiesRouter.patch(
273
380
  `/:type/:instanceId/inbox/:messageKey`,
274
381
  withExistingEntity,
275
382
  withSchema(inboxMessageBodySchema),
383
+ withEntityPermission(`write`),
276
384
  updateInboxMessage
277
385
  )
278
386
  entitiesRouter.delete(
279
387
  `/:type/:instanceId/inbox/:messageKey`,
280
388
  withExistingEntity,
389
+ withEntityPermission(`write`),
281
390
  deleteInboxMessage
282
391
  )
283
392
  entitiesRouter.post(
284
393
  `/:type/:instanceId/fork`,
285
394
  withExistingEntity,
286
395
  withSchema(forkBodySchema),
396
+ withEntityPermission(`fork`),
287
397
  forkEntity
288
398
  )
289
399
  entitiesRouter.post(
290
400
  `/:type/:instanceId/tags/:tagKey`,
291
401
  withExistingEntity,
292
402
  withSchema(setTagBodySchema),
403
+ withEntityPermission(`write`),
293
404
  setTag
294
405
  )
295
406
  entitiesRouter.delete(
296
407
  `/:type/:instanceId/tags/:tagKey`,
297
408
  withExistingEntity,
409
+ withEntityPermission(`write`),
298
410
  deleteTag
299
411
  )
300
412
  entitiesRouter.put(
301
413
  `/:type/:instanceId/schedules/:scheduleId`,
302
414
  withExistingEntity,
303
415
  withSchema(scheduleBodySchema),
416
+ withEntityPermission(`schedule`),
304
417
  upsertSchedule
305
418
  )
306
419
  entitiesRouter.delete(
307
420
  `/:type/:instanceId/schedules/:scheduleId`,
308
421
  withExistingEntity,
422
+ withEntityPermission(`schedule`),
309
423
  deleteSchedule
310
424
  )
311
425
  entitiesRouter.put(
312
426
  `/:type/:instanceId/event-source-subscriptions/:subscriptionId`,
313
427
  withExistingEntity,
314
428
  withSchema(eventSourceSubscriptionBodySchema),
429
+ withEntityPermission(`write`),
315
430
  upsertEventSourceSubscription
316
431
  )
317
432
  entitiesRouter.delete(
318
433
  `/:type/:instanceId/event-source-subscriptions/:subscriptionId`,
319
434
  withExistingEntity,
435
+ withEntityPermission(`write`),
320
436
  deleteEventSourceSubscription
321
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
+ )
322
457
 
323
458
  function entityUrlFromSegments(
324
459
  type: string,
@@ -492,6 +627,31 @@ function rejectPrincipalEntityMutation(
492
627
  )
493
628
  }
494
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
+
495
655
  async function withExistingEntity(
496
656
  request: AgentsRouteRequest,
497
657
  ctx: TenantContext
@@ -563,9 +723,96 @@ async function withSpawnableEntityType(
563
723
  )
564
724
  }
565
725
 
726
+ request.spawnRoute = { entityType }
566
727
  return undefined
567
728
  }
568
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
+
569
816
  async function listEntities(
570
817
  { query }: AgentsRouteRequest,
571
818
  ctx: TenantContext
@@ -575,10 +822,61 @@ async function listEntities(
575
822
  status: firstQueryValue(query.status),
576
823
  parent: firstQueryValue(query.parent),
577
824
  created_by: firstQueryValue(query.created_by),
825
+ readableBy: {
826
+ ...principalSubject(ctx.principal),
827
+ bypass: isPermissionBypassPrincipal(ctx),
828
+ },
578
829
  })
579
830
  return json(entities.map((entity) => toPublicEntity(entity)))
580
831
  }
581
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
+
582
880
  async function upsertSchedule(
583
881
  request: AgentsRouteRequest,
584
882
  ctx: TenantContext
@@ -794,6 +1092,13 @@ async function forkEntity(
794
1092
  const result = await ctx.entityManager.forkSubtree(entityUrl, {
795
1093
  rootInstanceId: parsed.instance_id,
796
1094
  waitTimeoutMs: parsed.waitTimeoutMs,
1095
+ createdBy: ctx.principal.url,
1096
+ ...(parsed.fork_pointer && {
1097
+ forkPointer: {
1098
+ offset: parsed.fork_pointer.offset,
1099
+ subOffset: parsed.fork_pointer.sub_offset,
1100
+ },
1101
+ }),
797
1102
  })
798
1103
  for (const forkedEntity of result.entities) {
799
1104
  await linkEntityDispatchSubscription(ctx, forkedEntity)
@@ -820,6 +1125,26 @@ async function sendEntity(
820
1125
  `Request from must match Electric-Principal`
821
1126
  )
822
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
+ }
823
1148
  await ctx.entityManager.ensurePrincipal(principal)
824
1149
  const { entityUrl, entity } = requireExistingEntityRoute(request)
825
1150
 
@@ -828,28 +1153,25 @@ async function sendEntity(
828
1153
  : await backfillEntityDispatchPolicy(ctx, entity)
829
1154
  await linkEntityDispatchSubscription(ctx, dispatchEntity)
830
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
+
831
1167
  if (parsed.afterMs && parsed.afterMs > 0) {
832
1168
  await ctx.entityManager.enqueueDelayedSend(
833
1169
  entityUrl,
834
- {
835
- from: principal.url,
836
- payload: parsed.payload,
837
- key: parsed.key,
838
- type: parsed.type,
839
- mode: parsed.mode,
840
- position: parsed.position,
841
- },
1170
+ sendReq,
842
1171
  new Date(Date.now() + parsed.afterMs)
843
1172
  )
844
1173
  } else {
845
- await ctx.entityManager.send(entityUrl, {
846
- from: principal.url,
847
- payload: parsed.payload,
848
- key: parsed.key,
849
- type: parsed.type,
850
- mode: parsed.mode,
851
- position: parsed.position,
852
- })
1174
+ await ctx.entityManager.send(entityUrl, sendReq)
853
1175
  }
854
1176
 
855
1177
  return status(204)
@@ -969,10 +1291,30 @@ async function spawnEntity(
969
1291
  tags: parsed.tags,
970
1292
  parent: parsed.parent,
971
1293
  dispatch_policy: dispatchPolicy,
1294
+ sandbox: parsed.sandbox,
972
1295
  initialMessage: undefined,
973
1296
  wake: parsed.wake,
974
1297
  created_by: principal.url,
975
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
+ }
976
1318
  const linkBeforeInitialMessage =
977
1319
  parsed.initialMessage !== undefined &&
978
1320
  shouldLinkDispatchBeforeInitialMessage(dispatchPolicy)