@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.
@@ -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
- extends JsonRouteRequest {}
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(`/:name`, getEntityType)
90
- entityTypesRouter.delete(`/:name`, deleteEntityType)
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
- return json(entityTypes.map((entityType) => toPublicEntityType(entityType)))
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
- const entityType = await ctx.entityManager.registry.getEntityType(
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
 
@@ -85,6 +85,7 @@ export function applyCors(
85
85
  `content-type`,
86
86
  `authorization`,
87
87
  `electric-claim-token`,
88
+ `electric-owner-entity`,
88
89
  ELECTRIC_PRINCIPAL_HEADER,
89
90
  `ngrok-skip-browser-warning`,
90
91
  ].join(`, `)
@@ -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
+ })
package/src/scheduler.ts CHANGED
@@ -6,6 +6,8 @@ import type { PgClient } from './db/index.js'
6
6
  export interface DelayedSendPayload {
7
7
  entityUrl: string
8
8
  from?: string
9
+ from_principal?: string
10
+ from_agent?: string
9
11
  payload: unknown
10
12
  key?: string
11
13
  type?: string