@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.
- package/dist/entrypoint.js +1530 -256
- package/dist/index.cjs +1517 -232
- package/dist/index.d.cts +1359 -212
- package/dist/index.d.ts +1359 -212
- package/dist/index.js +1519 -234
- package/drizzle/0010_sandbox_profiles.sql +5 -0
- package/drizzle/0011_entity_permissions.sql +100 -0
- package/drizzle/0012_horton_user_manage_permission.sql +25 -0
- package/drizzle/0013_worker_user_manage_permission.sql +25 -0
- package/drizzle/meta/_journal.json +28 -0
- package/package.json +7 -7
- package/src/db/schema.ts +200 -0
- package/src/electric-agents-types.ts +147 -2
- package/src/entity-bridge-manager.ts +57 -6
- package/src/entity-manager.ts +411 -62
- package/src/entity-projector.ts +79 -17
- package/src/entity-registry.ts +681 -5
- package/src/index.ts +11 -0
- package/src/permissions.ts +239 -0
- package/src/routing/context.ts +2 -0
- package/src/routing/durable-streams-router.ts +125 -4
- package/src/routing/electric-proxy-router.ts +4 -0
- package/src/routing/entities-router.ts +362 -20
- package/src/routing/entity-types-router.ts +244 -15
- package/src/routing/hooks.ts +1 -0
- package/src/routing/observations-router.ts +2 -1
- package/src/routing/runners-router.ts +10 -0
- package/src/routing/sandbox.ts +173 -0
- package/src/runtime.ts +34 -0
- package/src/sandbox-choice-schema.ts +28 -0
- package/src/scheduler.ts +2 -0
- package/src/server.ts +5 -0
- package/src/stream-client.ts +17 -1
- package/src/utils/server-utils.ts +192 -12
- package/src/wake-registry.ts +30 -11
|
@@ -8,22 +8,27 @@ import { dispatchPolicySchema } from '../dispatch-policy-schema.js'
|
|
|
8
8
|
import { ElectricAgentsError } from '../entity-manager.js'
|
|
9
9
|
import {
|
|
10
10
|
ErrCodeNotFound,
|
|
11
|
+
ErrCodeInvalidRequest,
|
|
12
|
+
ErrCodeUnauthorized,
|
|
11
13
|
ErrCodeServeEndpointNameMismatch,
|
|
12
14
|
ErrCodeServeEndpointUnreachable,
|
|
13
15
|
} from '../electric-agents-types.js'
|
|
14
16
|
import { apiError } from '../electric-agents-http.js'
|
|
15
17
|
import { routeBody, withSchema } from './schema.js'
|
|
16
18
|
import { rewriteLoopbackWebhookUrl } from '../utils/webhook-url.js'
|
|
19
|
+
import { canAccessEntityType, canRegisterEntityType } from '../permissions.js'
|
|
17
20
|
import type {
|
|
18
21
|
ElectricAgentsEntityType,
|
|
19
22
|
RegisterEntityTypeRequest,
|
|
23
|
+
EntityTypePermissionGrantInput,
|
|
20
24
|
} from '../electric-agents-types.js'
|
|
21
25
|
import type { JsonRouteRequest } from './schema.js'
|
|
22
26
|
import type { RouterType } from 'itty-router'
|
|
23
27
|
import type { TenantContext } from './context.js'
|
|
24
28
|
|
|
25
|
-
export interface ElectricAgentsEntityTypeRouteRequest
|
|
26
|
-
|
|
29
|
+
export interface ElectricAgentsEntityTypeRouteRequest extends JsonRouteRequest {
|
|
30
|
+
entityTypeRoute?: { entityType: ElectricAgentsEntityType }
|
|
31
|
+
}
|
|
27
32
|
|
|
28
33
|
type EntityTypeRouteArgs = [TenantContext]
|
|
29
34
|
type EntityTypeRouteResult = Response | undefined
|
|
@@ -41,6 +46,19 @@ type PublicEntityTypeResponse = ElectricAgentsEntityType & {
|
|
|
41
46
|
const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown())
|
|
42
47
|
const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema)
|
|
43
48
|
|
|
49
|
+
const typePermissionGrantInputSchema = Type.Object(
|
|
50
|
+
{
|
|
51
|
+
subject_kind: Type.Union([
|
|
52
|
+
Type.Literal(`principal`),
|
|
53
|
+
Type.Literal(`principal_kind`),
|
|
54
|
+
]),
|
|
55
|
+
subject_value: Type.String(),
|
|
56
|
+
permission: Type.Union([Type.Literal(`spawn`), Type.Literal(`manage`)]),
|
|
57
|
+
expires_at: Type.Optional(Type.String()),
|
|
58
|
+
},
|
|
59
|
+
{ additionalProperties: false }
|
|
60
|
+
)
|
|
61
|
+
|
|
44
62
|
const registerEntityTypeBodySchema = Type.Object(
|
|
45
63
|
{
|
|
46
64
|
name: Type.Optional(Type.String()),
|
|
@@ -50,6 +68,9 @@ const registerEntityTypeBodySchema = Type.Object(
|
|
|
50
68
|
state_schemas: Type.Optional(schemaMapSchema),
|
|
51
69
|
serve_endpoint: Type.Optional(Type.String()),
|
|
52
70
|
default_dispatch_policy: Type.Optional(dispatchPolicySchema),
|
|
71
|
+
permission_grants: Type.Optional(
|
|
72
|
+
Type.Array(typePermissionGrantInputSchema)
|
|
73
|
+
),
|
|
53
74
|
},
|
|
54
75
|
{ additionalProperties: false }
|
|
55
76
|
)
|
|
@@ -66,6 +87,7 @@ type RegisterEntityTypeBody = Static<typeof registerEntityTypeBodySchema>
|
|
|
66
87
|
type AmendEntityTypeSchemasBody = Static<
|
|
67
88
|
typeof amendEntityTypeSchemasBodySchema
|
|
68
89
|
>
|
|
90
|
+
type TypePermissionGrantInput = EntityTypePermissionGrantInput
|
|
69
91
|
|
|
70
92
|
export const entityTypesRouter: ElectricAgentsEntityTypeRoutes = Router<
|
|
71
93
|
ElectricAgentsEntityTypeRouteRequest,
|
|
@@ -79,15 +101,47 @@ entityTypesRouter.get(`/`, listEntityTypes)
|
|
|
79
101
|
entityTypesRouter.post(
|
|
80
102
|
`/`,
|
|
81
103
|
withSchema(registerEntityTypeBodySchema),
|
|
104
|
+
withEntityTypeRegistrationPermission,
|
|
82
105
|
registerEntityType
|
|
83
106
|
)
|
|
84
107
|
entityTypesRouter.patch(
|
|
85
108
|
`/:name/schemas`,
|
|
109
|
+
withExistingEntityType,
|
|
110
|
+
withEntityTypeManagePermission,
|
|
86
111
|
withSchema(amendEntityTypeSchemasBodySchema),
|
|
87
112
|
amendSchemas
|
|
88
113
|
)
|
|
89
|
-
entityTypesRouter.get(
|
|
90
|
-
|
|
114
|
+
entityTypesRouter.get(
|
|
115
|
+
`/:name`,
|
|
116
|
+
withExistingEntityType,
|
|
117
|
+
withEntityTypeSpawnPermission,
|
|
118
|
+
getEntityType
|
|
119
|
+
)
|
|
120
|
+
entityTypesRouter.delete(
|
|
121
|
+
`/:name`,
|
|
122
|
+
withExistingEntityType,
|
|
123
|
+
withEntityTypeManagePermission,
|
|
124
|
+
deleteEntityType
|
|
125
|
+
)
|
|
126
|
+
entityTypesRouter.get(
|
|
127
|
+
`/:name/grants`,
|
|
128
|
+
withExistingEntityType,
|
|
129
|
+
withEntityTypeManagePermission,
|
|
130
|
+
listTypePermissionGrants
|
|
131
|
+
)
|
|
132
|
+
entityTypesRouter.post(
|
|
133
|
+
`/:name/grants`,
|
|
134
|
+
withExistingEntityType,
|
|
135
|
+
withSchema(typePermissionGrantInputSchema),
|
|
136
|
+
withEntityTypeManagePermission,
|
|
137
|
+
createTypePermissionGrant
|
|
138
|
+
)
|
|
139
|
+
entityTypesRouter.delete(
|
|
140
|
+
`/:name/grants/:grantId`,
|
|
141
|
+
withExistingEntityType,
|
|
142
|
+
withEntityTypeManagePermission,
|
|
143
|
+
deleteTypePermissionGrant
|
|
144
|
+
)
|
|
91
145
|
|
|
92
146
|
async function registerEntityType(
|
|
93
147
|
request: ElectricAgentsEntityTypeRouteRequest,
|
|
@@ -105,6 +159,7 @@ async function registerEntityType(
|
|
|
105
159
|
}
|
|
106
160
|
|
|
107
161
|
const entityType = await ctx.entityManager.registerEntityType(normalized)
|
|
162
|
+
await applyRegistrationPermissionGrants(ctx, entityType.name, normalized)
|
|
108
163
|
return json(toPublicEntityType(entityType), { status: 201 })
|
|
109
164
|
}
|
|
110
165
|
|
|
@@ -113,7 +168,102 @@ async function listEntityTypes(
|
|
|
113
168
|
ctx: TenantContext
|
|
114
169
|
): Promise<EntityTypeRouteResult> {
|
|
115
170
|
const entityTypes = await ctx.entityManager.registry.listEntityTypes()
|
|
116
|
-
|
|
171
|
+
const visible: Array<ElectricAgentsEntityType> = []
|
|
172
|
+
for (const entityType of entityTypes) {
|
|
173
|
+
if (await canAccessEntityType(ctx, entityType, `spawn`)) {
|
|
174
|
+
visible.push(entityType)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return json(visible.map((entityType) => toPublicEntityType(entityType)))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function withExistingEntityType(
|
|
181
|
+
request: ElectricAgentsEntityTypeRouteRequest,
|
|
182
|
+
ctx: TenantContext
|
|
183
|
+
): Promise<EntityTypeRouteResult> {
|
|
184
|
+
const entityType = await ctx.entityManager.registry.getEntityType(
|
|
185
|
+
request.params.name
|
|
186
|
+
)
|
|
187
|
+
if (!entityType) {
|
|
188
|
+
return apiError(404, ErrCodeNotFound, `Entity type not found`)
|
|
189
|
+
}
|
|
190
|
+
request.entityTypeRoute = { entityType }
|
|
191
|
+
return undefined
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function withEntityTypeManagePermission(
|
|
195
|
+
request: ElectricAgentsEntityTypeRouteRequest,
|
|
196
|
+
ctx: TenantContext
|
|
197
|
+
): Promise<EntityTypeRouteResult> {
|
|
198
|
+
const entityType = request.entityTypeRoute?.entityType
|
|
199
|
+
if (!entityType) {
|
|
200
|
+
throw new Error(`entity type middleware did not run`)
|
|
201
|
+
}
|
|
202
|
+
if (
|
|
203
|
+
await canAccessEntityType(ctx, entityType, `manage`, request as Request)
|
|
204
|
+
) {
|
|
205
|
+
return undefined
|
|
206
|
+
}
|
|
207
|
+
return apiError(
|
|
208
|
+
401,
|
|
209
|
+
ErrCodeUnauthorized,
|
|
210
|
+
`Principal is not allowed to manage ${entityType.name}`
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function withEntityTypeSpawnPermission(
|
|
215
|
+
request: ElectricAgentsEntityTypeRouteRequest,
|
|
216
|
+
ctx: TenantContext
|
|
217
|
+
): Promise<EntityTypeRouteResult> {
|
|
218
|
+
const entityType = request.entityTypeRoute?.entityType
|
|
219
|
+
if (!entityType) {
|
|
220
|
+
throw new Error(`entity type middleware did not run`)
|
|
221
|
+
}
|
|
222
|
+
if (await canAccessEntityType(ctx, entityType, `spawn`, request as Request)) {
|
|
223
|
+
return undefined
|
|
224
|
+
}
|
|
225
|
+
return apiError(
|
|
226
|
+
401,
|
|
227
|
+
ErrCodeUnauthorized,
|
|
228
|
+
`Principal is not allowed to spawn ${entityType.name}`
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function withEntityTypeRegistrationPermission(
|
|
233
|
+
request: ElectricAgentsEntityTypeRouteRequest,
|
|
234
|
+
ctx: TenantContext
|
|
235
|
+
): Promise<EntityTypeRouteResult> {
|
|
236
|
+
const parsed = normalizeEntityTypeRequest(
|
|
237
|
+
routeBody<RegisterEntityTypeBody>(request)
|
|
238
|
+
)
|
|
239
|
+
if (!parsed.name) {
|
|
240
|
+
return undefined
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const existing = await ctx.entityManager.registry.getEntityType(parsed.name)
|
|
244
|
+
if (existing) {
|
|
245
|
+
request.entityTypeRoute = { entityType: existing }
|
|
246
|
+
if (
|
|
247
|
+
await canAccessEntityType(ctx, existing, `manage`, request as Request)
|
|
248
|
+
) {
|
|
249
|
+
return undefined
|
|
250
|
+
}
|
|
251
|
+
return apiError(
|
|
252
|
+
401,
|
|
253
|
+
ErrCodeUnauthorized,
|
|
254
|
+
`Principal is not allowed to manage ${existing.name}`
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (await canRegisterEntityType(ctx, parsed, request as Request)) {
|
|
259
|
+
return undefined
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return apiError(
|
|
263
|
+
401,
|
|
264
|
+
ErrCodeUnauthorized,
|
|
265
|
+
`Principal is not allowed to register entity types`
|
|
266
|
+
)
|
|
117
267
|
}
|
|
118
268
|
|
|
119
269
|
async function discoverServeEndpoint(
|
|
@@ -141,10 +291,12 @@ async function discoverServeEndpoint(
|
|
|
141
291
|
}
|
|
142
292
|
|
|
143
293
|
manifest.serve_endpoint = parsed.serve_endpoint
|
|
294
|
+
manifest.permission_grants = parsed.permission_grants
|
|
144
295
|
|
|
145
296
|
const entityType = await ctx.entityManager.registerEntityType(
|
|
146
297
|
normalizeEntityTypeRequest(manifest)
|
|
147
298
|
)
|
|
299
|
+
await applyRegistrationPermissionGrants(ctx, entityType.name, manifest)
|
|
148
300
|
return json(toPublicEntityType(entityType), { status: 201 })
|
|
149
301
|
} catch (err) {
|
|
150
302
|
if (err instanceof ElectricAgentsError) {
|
|
@@ -161,17 +313,9 @@ async function discoverServeEndpoint(
|
|
|
161
313
|
}
|
|
162
314
|
|
|
163
315
|
async function getEntityType(
|
|
164
|
-
request: ElectricAgentsEntityTypeRouteRequest
|
|
165
|
-
ctx: TenantContext
|
|
316
|
+
request: ElectricAgentsEntityTypeRouteRequest
|
|
166
317
|
): Promise<EntityTypeRouteResult> {
|
|
167
|
-
|
|
168
|
-
request.params.name
|
|
169
|
-
)
|
|
170
|
-
if (!entityType) {
|
|
171
|
-
return apiError(404, ErrCodeNotFound, `Entity type not found`)
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return json(toPublicEntityType(entityType))
|
|
318
|
+
return json(toPublicEntityType(request.entityTypeRoute!.entityType))
|
|
175
319
|
}
|
|
176
320
|
|
|
177
321
|
async function amendSchemas(
|
|
@@ -195,6 +339,90 @@ async function deleteEntityType(
|
|
|
195
339
|
return status(204)
|
|
196
340
|
}
|
|
197
341
|
|
|
342
|
+
async function listTypePermissionGrants(
|
|
343
|
+
request: ElectricAgentsEntityTypeRouteRequest,
|
|
344
|
+
ctx: TenantContext
|
|
345
|
+
): Promise<EntityTypeRouteResult> {
|
|
346
|
+
const grants =
|
|
347
|
+
await ctx.entityManager.registry.listEntityTypePermissionGrants(
|
|
348
|
+
request.entityTypeRoute!.entityType.name
|
|
349
|
+
)
|
|
350
|
+
return json({ grants })
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function createTypePermissionGrant(
|
|
354
|
+
request: ElectricAgentsEntityTypeRouteRequest,
|
|
355
|
+
ctx: TenantContext
|
|
356
|
+
): Promise<EntityTypeRouteResult> {
|
|
357
|
+
const parsed = routeBody<TypePermissionGrantInput>(request)
|
|
358
|
+
const grant =
|
|
359
|
+
await ctx.entityManager.registry.createEntityTypePermissionGrant({
|
|
360
|
+
entityType: request.entityTypeRoute!.entityType.name,
|
|
361
|
+
permission: parsed.permission,
|
|
362
|
+
subjectKind: parsed.subject_kind,
|
|
363
|
+
subjectValue: parsed.subject_value,
|
|
364
|
+
expiresAt: parseExpiresAt(parsed.expires_at),
|
|
365
|
+
createdBy: ctx.principal.url,
|
|
366
|
+
})
|
|
367
|
+
return json(grant, { status: 201 })
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function deleteTypePermissionGrant(
|
|
371
|
+
request: ElectricAgentsEntityTypeRouteRequest,
|
|
372
|
+
ctx: TenantContext
|
|
373
|
+
): Promise<EntityTypeRouteResult> {
|
|
374
|
+
const deleted =
|
|
375
|
+
await ctx.entityManager.registry.deleteEntityTypePermissionGrant(
|
|
376
|
+
request.entityTypeRoute!.entityType.name,
|
|
377
|
+
parseGrantId(request)
|
|
378
|
+
)
|
|
379
|
+
return deleted
|
|
380
|
+
? status(204)
|
|
381
|
+
: apiError(404, ErrCodeNotFound, `Grant not found`)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function applyRegistrationPermissionGrants(
|
|
385
|
+
ctx: TenantContext,
|
|
386
|
+
entityType: string,
|
|
387
|
+
request: Pick<RegisterEntityTypeRequest, `permission_grants`>
|
|
388
|
+
): Promise<void> {
|
|
389
|
+
for (const grant of request.permission_grants ?? []) {
|
|
390
|
+
await ctx.entityManager.registry.ensureEntityTypePermissionGrant({
|
|
391
|
+
entityType,
|
|
392
|
+
permission: grant.permission,
|
|
393
|
+
subjectKind: grant.subject_kind,
|
|
394
|
+
subjectValue: grant.subject_value,
|
|
395
|
+
expiresAt: parseExpiresAt(grant.expires_at),
|
|
396
|
+
createdBy: ctx.principal.url,
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function parseGrantId(request: ElectricAgentsEntityTypeRouteRequest): number {
|
|
402
|
+
const grantId = Number.parseInt(String(request.params.grantId), 10)
|
|
403
|
+
if (!Number.isSafeInteger(grantId) || grantId <= 0) {
|
|
404
|
+
throw new ElectricAgentsError(
|
|
405
|
+
ErrCodeInvalidRequest,
|
|
406
|
+
`Invalid grant id`,
|
|
407
|
+
400
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
return grantId
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function parseExpiresAt(value: string | undefined): Date | undefined {
|
|
414
|
+
if (value === undefined) return undefined
|
|
415
|
+
const expiresAt = new Date(value)
|
|
416
|
+
if (Number.isNaN(expiresAt.getTime())) {
|
|
417
|
+
throw new ElectricAgentsError(
|
|
418
|
+
ErrCodeInvalidRequest,
|
|
419
|
+
`Invalid expires_at timestamp`,
|
|
420
|
+
400
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
return expiresAt
|
|
424
|
+
}
|
|
425
|
+
|
|
198
426
|
function normalizeEntityTypeRequest(
|
|
199
427
|
parsed: RegisterEntityTypeBody | RegisterEntityTypeRequest
|
|
200
428
|
): RegisterEntityTypeRequest {
|
|
@@ -213,6 +441,7 @@ function normalizeEntityTypeRequest(
|
|
|
213
441
|
targets: [{ type: `webhook`, url: serveEndpoint }],
|
|
214
442
|
} as RegisterEntityTypeRequest[`default_dispatch_policy`])
|
|
215
443
|
: undefined),
|
|
444
|
+
permission_grants: parsed.permission_grants,
|
|
216
445
|
}
|
|
217
446
|
}
|
|
218
447
|
|
package/src/routing/hooks.ts
CHANGED
|
@@ -56,7 +56,8 @@ async function ensureEntitiesMembershipStream(
|
|
|
56
56
|
): Promise<Response> {
|
|
57
57
|
const parsed = routeBody<EnsureEntitiesMembershipStreamBody>(request)
|
|
58
58
|
const result = await ctx.entityManager.ensureEntitiesMembershipStream(
|
|
59
|
-
parsed.tags ?? {}
|
|
59
|
+
parsed.tags ?? {},
|
|
60
|
+
ctx.principal
|
|
60
61
|
)
|
|
61
62
|
return json(result)
|
|
62
63
|
}
|
|
@@ -34,6 +34,13 @@ export type RunnersRoutes = RouterType<
|
|
|
34
34
|
RunnersRouteResult
|
|
35
35
|
>
|
|
36
36
|
|
|
37
|
+
const sandboxProfileBodySchema = Type.Object({
|
|
38
|
+
name: Type.String(),
|
|
39
|
+
label: Type.String(),
|
|
40
|
+
description: Type.Optional(Type.String()),
|
|
41
|
+
remote: Type.Optional(Type.Boolean()),
|
|
42
|
+
})
|
|
43
|
+
|
|
37
44
|
const registerRunnerBodySchema = Type.Object({
|
|
38
45
|
id: Type.String(),
|
|
39
46
|
owner_principal: Type.Optional(Type.String()),
|
|
@@ -51,6 +58,7 @@ const registerRunnerBodySchema = Type.Object({
|
|
|
51
58
|
Type.Union([Type.Literal(`enabled`), Type.Literal(`disabled`)])
|
|
52
59
|
),
|
|
53
60
|
wake_stream: Type.Optional(Type.String()),
|
|
61
|
+
sandbox_profiles: Type.Optional(Type.Array(sandboxProfileBodySchema)),
|
|
54
62
|
})
|
|
55
63
|
|
|
56
64
|
const heartbeatBodySchema = Type.Object({
|
|
@@ -234,6 +242,7 @@ async function registerRunner(
|
|
|
234
242
|
kind: parsed.kind,
|
|
235
243
|
adminStatus: parsed.admin_status,
|
|
236
244
|
wakeStream: parsed.wake_stream,
|
|
245
|
+
sandboxProfiles: parsed.sandbox_profiles,
|
|
237
246
|
})
|
|
238
247
|
await ctx.streamClient.ensure(runner.wake_stream, {
|
|
239
248
|
contentType: `application/json`,
|
|
@@ -639,6 +648,7 @@ async function notificationFromClaim(
|
|
|
639
648
|
streams: entity.streams,
|
|
640
649
|
tags: entity.tags,
|
|
641
650
|
spawnArgs: entity.spawn_args,
|
|
651
|
+
sandbox: entity.sandbox,
|
|
642
652
|
createdBy: entity.created_by,
|
|
643
653
|
},
|
|
644
654
|
principal: principalFromCreatedBy(entity.created_by),
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { ElectricAgentsError } from '../entity-manager.js'
|
|
2
|
+
import { ErrCodeInvalidRequest } from '../electric-agents-types.js'
|
|
3
|
+
import type {
|
|
4
|
+
DispatchPolicy,
|
|
5
|
+
ElectricAgentsEntity,
|
|
6
|
+
EntitySandboxSelection,
|
|
7
|
+
SandboxChoice,
|
|
8
|
+
} from '../electric-agents-types.js'
|
|
9
|
+
import type { PostgresRegistry } from '../entity-registry.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve and validate a spawn's sandbox CHOICE into the {@link
|
|
13
|
+
* EntitySandboxSelection} persisted on the entity. Sibling of
|
|
14
|
+
* `dispatch-policy.ts`'s `resolveEffectiveDispatchPolicyForSpawn`: kept off the
|
|
15
|
+
* EntityManager so the spawn path reads as composed resolution steps.
|
|
16
|
+
*
|
|
17
|
+
* Profiles are a per-runner concern: each runner advertises what it supports.
|
|
18
|
+
* When the spawn pins a runner via dispatch_policy, the chosen profile must be
|
|
19
|
+
* in that runner's advertised set; otherwise we'd persist an unserviceable
|
|
20
|
+
* choice that fails late at first wake. For unpinned dispatch (webhook /
|
|
21
|
+
* parent-inherited) we can't pick a target ahead of time, so we fall back to a
|
|
22
|
+
* tenant-wide "some runner offers this" check — better than nothing.
|
|
23
|
+
*/
|
|
24
|
+
export async function resolveSandboxForSpawn(
|
|
25
|
+
registry: PostgresRegistry,
|
|
26
|
+
dispatchPolicy: DispatchPolicy | undefined,
|
|
27
|
+
requested: SandboxChoice | undefined,
|
|
28
|
+
parentEntity: ElectricAgentsEntity | null
|
|
29
|
+
): Promise<EntitySandboxSelection | undefined> {
|
|
30
|
+
if (!requested) return undefined
|
|
31
|
+
|
|
32
|
+
const choice = applyInheritedSandbox(requested, parentEntity)
|
|
33
|
+
// `inherit` against a parent with no shareable (keyed) sandbox yields none.
|
|
34
|
+
if (!choice) return undefined
|
|
35
|
+
|
|
36
|
+
const chosenName = choice.profile
|
|
37
|
+
if (!chosenName) {
|
|
38
|
+
throw new ElectricAgentsError(
|
|
39
|
+
ErrCodeInvalidRequest,
|
|
40
|
+
`sandbox requires a "profile" (or "inherit": true with a parent that has a shared sandbox).`,
|
|
41
|
+
400
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const chosenIsRemote = await resolveChosenProfileRemote(
|
|
46
|
+
registry,
|
|
47
|
+
chosenName,
|
|
48
|
+
dispatchPolicy
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
assertSharedSandboxColocated(choice.key, chosenIsRemote, dispatchPolicy)
|
|
52
|
+
|
|
53
|
+
// Persist the selection. Only an explicit/inherited `key` is stored (it's
|
|
54
|
+
// cross-entity, so the guard above applies); a `scope` is kept so the wake
|
|
55
|
+
// can derive the key, but no `key` is stored for it — leaving the
|
|
56
|
+
// co-location guard correctly keyed on genuine cross-entity sharing.
|
|
57
|
+
const selection: EntitySandboxSelection = { profile: chosenName }
|
|
58
|
+
if (choice.key !== undefined) selection.key = choice.key
|
|
59
|
+
else if (choice.scope !== undefined) selection.scope = choice.scope
|
|
60
|
+
if (choice.persistent !== undefined) selection.persistent = choice.persistent
|
|
61
|
+
// Store ownership only when this entity is an attacher; owner is the
|
|
62
|
+
// default, so it's left implicit (the wake resolver defaults to owner).
|
|
63
|
+
if (choice.owner === false) selection.owner = false
|
|
64
|
+
return selection
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve `inherit` against the parent's *stored* sandbox. `inherit` reuses the
|
|
69
|
+
* parent's keyed sandbox as a non-owner (attach-only). It's graceful: if the
|
|
70
|
+
* parent has no shareable (keyed) sandbox the child simply gets none (returns
|
|
71
|
+
* `undefined`), so `spawn_worker` can always request inheritance without
|
|
72
|
+
* breaking unkeyed parents. (A running parent wake resolves inherit to its live
|
|
73
|
+
* explicit key in the runtime instead — this server-side path covers direct API
|
|
74
|
+
* callers, where only the parent's *stored* explicit key is available.)
|
|
75
|
+
*
|
|
76
|
+
* For a non-inherit choice the request passes through unchanged.
|
|
77
|
+
*
|
|
78
|
+
* NOTE: `inherit: true` takes the parent's identity AND durability wholesale —
|
|
79
|
+
* any sibling field on the request (e.g. a caller-supplied `persistent: false`)
|
|
80
|
+
* is intentionally ignored, because a child attaches to the parent's existing
|
|
81
|
+
* sandbox and cannot change how that sandbox is torn down. `sandboxChoiceSchema`
|
|
82
|
+
* permits the `{ inherit: true, persistent: ... }` combination, so the
|
|
83
|
+
* precedence is resolved here rather than rejected at the schema level.
|
|
84
|
+
*/
|
|
85
|
+
function applyInheritedSandbox(
|
|
86
|
+
requested: SandboxChoice,
|
|
87
|
+
parentEntity: ElectricAgentsEntity | null
|
|
88
|
+
): SandboxChoice | undefined {
|
|
89
|
+
if (!requested.inherit) return requested
|
|
90
|
+
const parentKey = parentEntity?.sandbox?.key
|
|
91
|
+
if (!parentKey) return undefined
|
|
92
|
+
return {
|
|
93
|
+
profile: parentEntity!.sandbox!.profile,
|
|
94
|
+
key: parentKey,
|
|
95
|
+
// Adopt the parent's durability; an explicit key has no scope. The child
|
|
96
|
+
// attaches to (never owns) the parent's sandbox.
|
|
97
|
+
persistent: parentEntity!.sandbox!.persistent,
|
|
98
|
+
owner: false,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Validate the chosen profile is advertised by the relevant runner(s) and
|
|
104
|
+
* determine whether it is a remote (off-host) sandbox, reachable from any
|
|
105
|
+
* runner. Defaults to host-local (co-location required) unless every relevant
|
|
106
|
+
* advertisement marks it remote. Throws if the profile is unserviceable.
|
|
107
|
+
*/
|
|
108
|
+
async function resolveChosenProfileRemote(
|
|
109
|
+
registry: PostgresRegistry,
|
|
110
|
+
chosenName: string,
|
|
111
|
+
dispatchPolicy: DispatchPolicy | undefined
|
|
112
|
+
): Promise<boolean> {
|
|
113
|
+
const runnerIds: Array<string> = []
|
|
114
|
+
for (const target of dispatchPolicy?.targets ?? []) {
|
|
115
|
+
if (target.type === `runner`) runnerIds.push(target.runnerId)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (runnerIds.length > 0) {
|
|
119
|
+
let allRemote = true
|
|
120
|
+
for (const runnerId of runnerIds) {
|
|
121
|
+
const runner = await registry.getRunner(runnerId)
|
|
122
|
+
const advertised = runner?.sandbox_profiles ?? []
|
|
123
|
+
const match = advertised.find((p) => p.name === chosenName)
|
|
124
|
+
if (!match) {
|
|
125
|
+
throw new ElectricAgentsError(
|
|
126
|
+
ErrCodeInvalidRequest,
|
|
127
|
+
`sandbox profile "${chosenName}" is not advertised by runner "${runnerId}" (advertised: ${advertised.map((p) => p.name).join(`, `) || `(none)`}).`,
|
|
128
|
+
400
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
if (match.remote !== true) allRemote = false
|
|
132
|
+
}
|
|
133
|
+
return allRemote
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const available = await registry.listSandboxProfiles()
|
|
137
|
+
const matches = available.filter((p) => p.name === chosenName)
|
|
138
|
+
if (matches.length === 0) {
|
|
139
|
+
throw new ElectricAgentsError(
|
|
140
|
+
ErrCodeInvalidRequest,
|
|
141
|
+
`sandbox profile "${chosenName}" is not offered by any registered runner (available: ${[...new Set(available.map((p) => p.name))].join(`, `) || `(none)`}).`,
|
|
142
|
+
400
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
// Only skip the co-location guard when every advertiser of this name is
|
|
146
|
+
// remote — a same-named host-local profile on another runner could
|
|
147
|
+
// otherwise land a collaborator on the wrong host.
|
|
148
|
+
return matches.every((p) => p.remote === true)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Co-location: a shared *local* sandbox lives on one host, so every
|
|
153
|
+
* collaborator must be pinned to the same single runner. Subagents inherit the
|
|
154
|
+
* parent's dispatch policy, so this holds once the root is pinned. A shared
|
|
155
|
+
* *remote* sandbox is reachable from any runner, so the guard does not apply.
|
|
156
|
+
*/
|
|
157
|
+
function assertSharedSandboxColocated(
|
|
158
|
+
key: string | undefined,
|
|
159
|
+
chosenIsRemote: boolean,
|
|
160
|
+
dispatchPolicy: DispatchPolicy | undefined
|
|
161
|
+
): void {
|
|
162
|
+
if (key === undefined || chosenIsRemote) return
|
|
163
|
+
const targets = dispatchPolicy?.targets ?? []
|
|
164
|
+
const pinnedToSingleRunner =
|
|
165
|
+
targets.length === 1 && targets[0]?.type === `runner`
|
|
166
|
+
if (!pinnedToSingleRunner) {
|
|
167
|
+
throw new ElectricAgentsError(
|
|
168
|
+
ErrCodeInvalidRequest,
|
|
169
|
+
`a shared sandbox (sandbox.key / sandbox.inherit) requires the entity to be pinned to a single runner via dispatch_policy, so all collaborators share one host.`,
|
|
170
|
+
400
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -316,6 +316,8 @@ export class ElectricAgentsTenantRuntime {
|
|
|
316
316
|
payload.entityUrl,
|
|
317
317
|
{
|
|
318
318
|
from: payload.from,
|
|
319
|
+
from_principal: payload.from_principal,
|
|
320
|
+
from_agent: payload.from_agent,
|
|
319
321
|
payload: payload.payload,
|
|
320
322
|
key: payload.key ?? `scheduled-task-${taskId}`,
|
|
321
323
|
type: payload.type,
|
|
@@ -461,6 +463,7 @@ export class ElectricAgentsTenantRuntime {
|
|
|
461
463
|
{
|
|
462
464
|
entityUrl: targetUrl,
|
|
463
465
|
from: senderUrl,
|
|
466
|
+
from_agent: senderUrl,
|
|
464
467
|
payload: value.payload,
|
|
465
468
|
key: `scheduled-${producerId}`,
|
|
466
469
|
type:
|
|
@@ -499,6 +502,14 @@ export class ElectricAgentsTenantRuntime {
|
|
|
499
502
|
manifestKey,
|
|
500
503
|
sourceRef
|
|
501
504
|
)
|
|
505
|
+
|
|
506
|
+
const sharedStateId =
|
|
507
|
+
operation === `delete` ? undefined : this.extractSharedStateId(value)
|
|
508
|
+
await this.manager.registry.replaceSharedStateLink(
|
|
509
|
+
ownerEntityUrl,
|
|
510
|
+
manifestKey,
|
|
511
|
+
sharedStateId
|
|
512
|
+
)
|
|
502
513
|
}
|
|
503
514
|
|
|
504
515
|
private extractEntitiesSourceRef(
|
|
@@ -514,6 +525,29 @@ export class ElectricAgentsTenantRuntime {
|
|
|
514
525
|
return undefined
|
|
515
526
|
}
|
|
516
527
|
|
|
528
|
+
private extractSharedStateId(
|
|
529
|
+
manifest: Record<string, unknown> | undefined
|
|
530
|
+
): string | undefined {
|
|
531
|
+
if (manifest?.kind === `shared-state` && typeof manifest.id === `string`) {
|
|
532
|
+
return manifest.id
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (manifest?.kind !== `source` || manifest.sourceType !== `db`) {
|
|
536
|
+
return undefined
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (typeof manifest.sourceRef === `string`) {
|
|
540
|
+
return manifest.sourceRef
|
|
541
|
+
}
|
|
542
|
+
const config =
|
|
543
|
+
typeof manifest.config === `object` &&
|
|
544
|
+
manifest.config !== null &&
|
|
545
|
+
!Array.isArray(manifest.config)
|
|
546
|
+
? (manifest.config as Record<string, unknown>)
|
|
547
|
+
: undefined
|
|
548
|
+
return typeof config?.id === `string` ? config.id : undefined
|
|
549
|
+
}
|
|
550
|
+
|
|
517
551
|
private async maybeMarkEntityIdleAfterRunFinished(
|
|
518
552
|
entityUrl: string
|
|
519
553
|
): Promise<void> {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Type } from '@sinclair/typebox'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wire schema for a spawn-time sandbox CHOICE (the request input), as opposed to
|
|
5
|
+
* the resolved {@link import('./electric-agents-types.js').EntitySandboxSelection}
|
|
6
|
+
* persisted on the entity. The matching `SandboxChoice` type is hand-maintained
|
|
7
|
+
* in `electric-agents-types.ts` — mirrors how `dispatchPolicySchema` pairs with
|
|
8
|
+
* the `DispatchPolicy` type in `dispatch-policy-schema.ts`.
|
|
9
|
+
*
|
|
10
|
+
* Validation happens once, at the router boundary (this schema is embedded in
|
|
11
|
+
* the spawn body schema); the spawn resolver consumes already-validated input,
|
|
12
|
+
* so there is intentionally no separate `parse` helper here.
|
|
13
|
+
*/
|
|
14
|
+
export const sandboxChoiceSchema = Type.Object({
|
|
15
|
+
profile: Type.Optional(Type.String()),
|
|
16
|
+
// Explicit cross-entity identity — entities with the same key collaborate on
|
|
17
|
+
// one workspace. `inherit` reuses the parent entity's resolved sandbox.
|
|
18
|
+
key: Type.Optional(Type.String()),
|
|
19
|
+
// Identity scope when no explicit `key`: per-entity (default) or per-wake.
|
|
20
|
+
scope: Type.Optional(
|
|
21
|
+
Type.Union([Type.Literal(`entity`), Type.Literal(`wake`)])
|
|
22
|
+
),
|
|
23
|
+
// Idle-teardown durability; defaults by scope when unset.
|
|
24
|
+
persistent: Type.Optional(Type.Boolean()),
|
|
25
|
+
// Whether this entity owns the sandbox (default) or only attaches to one.
|
|
26
|
+
owner: Type.Optional(Type.Boolean()),
|
|
27
|
+
inherit: Type.Optional(Type.Boolean()),
|
|
28
|
+
})
|