Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/agents-server-permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@electric-ax/agents-server": patch
"@electric-ax/agents-runtime": patch
"@electric-ax/agents": patch
---

Add owner-default agents-server permissions with type-level spawn grants, entity grants, effective permission materialization, principal-scoped entity observation streams, shared-state access links, runtime registration permission grants, and default user spawn grants for built-in Horton and Worker types.

Existing entity observation bridges are rebuilt after upgrade because pre-permission bridge rows do not include principal attribution.

Entity `manage` grants participate in read visibility, entity-type `manage` grants participate in spawn visibility, and broad parented spawn-time grants require `manage` on the parent.
7 changes: 6 additions & 1 deletion packages/agents-runtime/src/agents-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,14 @@ export function createAgentsClient(config: AgentsClientConfig): AgentsClient {
}

if (source.sourceType === `entities`) {
await serverClient.ensureEntitiesMembershipStream(
const ensured = await serverClient.ensureEntitiesMembershipStream(
(source as EntitiesObservationSource).tags
)
source = {
...source,
sourceRef: ensured.sourceRef,
streamUrl: ensured.streamUrl,
}
}

if (!source.streamUrl || !source.schema) {
Expand Down
3 changes: 3 additions & 0 deletions packages/agents-runtime/src/create-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,9 @@ export function createRuntimeRouter(
? mapSchemas(definition.stateSchemas)
: {}),
},
...(definition.permissionGrants && {
permission_grants: definition.permissionGrants,
}),
}

const defaultDispatchPolicy = defaultDispatchPolicyForType?.(name)
Expand Down
1 change: 1 addition & 0 deletions packages/agents-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type {
AgentConfig,
AgentModel,
EntityDefinition,
EntityTypePermissionGrantDefinition,
EntityActionsFactory,
EntityActionMap,
EntityArgs,
Expand Down
39 changes: 30 additions & 9 deletions packages/agents-runtime/src/process-wake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1396,7 +1396,7 @@ export async function processWake(
): Promise<EntityStreamDBWithActions> => {
const ssStreamPath = serverClient.getSharedStateStreamPath(ssId)
if (mode === `create`) {
await serverClient.ensureSharedStateStream(ssId)
await serverClient.ensureSharedStateStream(ssId, entityUrl)
}
const ssStreamUrl = appendPathToUrl(baseUrl, ssStreamPath)
const ssCollections: Record<string, CollectionDefinition> = {}
Expand Down Expand Up @@ -1607,6 +1607,7 @@ export async function processWake(
source: ObservationSource,
wake?: Wake
): Promise<ObservationHandle> => {
let observedSource = source
// Self-observation
if (
source.sourceType === `entity` &&
Expand Down Expand Up @@ -1647,24 +1648,44 @@ export async function processWake(
}

if (source.sourceType === `entities`) {
await serverClient.ensureEntitiesMembershipStream(
const ensured = await serverClient.ensureEntitiesMembershipStream(
(source as EntitiesObservationSource).tags
)
const originalEntry = source.toManifestEntry() as Record<
string,
unknown
>
observedSource = {
...source,
sourceRef: ensured.sourceRef,
streamUrl: ensured.streamUrl,
toManifestEntry() {
return {
...originalEntry,
key: `source:entities:${ensured.sourceRef}`,
sourceRef: ensured.sourceRef,
config: {
...((originalEntry.config as Record<string, unknown>) ?? {}),
streamUrl: ensured.streamUrl,
},
} as unknown as ReturnType<ObservationSource[`toManifestEntry`]>
},
}
}

if (effectiveWake) {
const observeHandle = await setupCtx.observe(source, {
const observeHandle = await setupCtx.observe(observedSource, {
wake: effectiveWake,
})

const sourceUrl =
sourceWakeConfig?.sourceUrl ??
(source.sourceType === `entity`
? (source as EntityObservationSource).entityUrl
: source.streamUrl)
(observedSource.sourceType === `entity`
? (observedSource as EntityObservationSource).entityUrl
: observedSource.streamUrl)
if (!sourceUrl) {
throw new Error(
`[agent-runtime] Cannot register wake for source '${source.sourceType}:${source.sourceRef}' without a source URL`
`[agent-runtime] Cannot register wake for source '${observedSource.sourceType}:${observedSource.sourceRef}' without a source URL`
)
}

Expand Down Expand Up @@ -1695,7 +1716,7 @@ export async function processWake(
? wake.includeResponse
: undefined
: sourceWakeConfig?.includeResponse,
manifestKey: source.toManifestEntry().key,
manifestKey: observedSource.toManifestEntry().key,
})

if (source.sourceType === `db`) {
Expand All @@ -1706,7 +1727,7 @@ export async function processWake(
return observeHandle
}

const observeHandle = await setupCtx.observe(source)
const observeHandle = await setupCtx.observe(observedSource)
if (source.sourceType === `db`) {
scheduleSharedStateWiring()
await waitForSharedStateWiring()
Expand Down
18 changes: 13 additions & 5 deletions packages/agents-runtime/src/runtime-server-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,10 @@ export interface RuntimeServerClient {
}) => Promise<Uint8Array>
spawnEntity: (options: SpawnEntityOptions) => Promise<RuntimeEntityInfo>
getEntity: (entityUrl: string) => Promise<RuntimeEntityInfo>
ensureSharedStateStream: (sharedStateId: string) => Promise<string>
ensureSharedStateStream: (
sharedStateId: string,
ownerEntityUrl?: string
) => Promise<string>
signalEntity: (options: SignalEntityOptions) => Promise<{ txid: number }>
ensureStream: (streamPath: string, contentType?: string) => Promise<string>
deleteEntity: (entityUrl: string) => Promise<void>
Expand Down Expand Up @@ -447,19 +450,24 @@ export function createRuntimeServerClient(
}

const ensureSharedStateStream = async (
sharedStateId: string
sharedStateId: string,
ownerEntityUrl?: string
): Promise<string> => {
const streamPath = getSharedStateStreamPath(sharedStateId)
return await ensureStream(streamPath, `application/json`)
return await ensureStream(streamPath, `application/json`, ownerEntityUrl)
}

const ensureStream = async (
streamPath: string,
contentType = `application/json`
contentType = `application/json`,
ownerEntityUrl?: string
): Promise<string> => {
const response = await request(streamPath, {
method: `PUT`,
headers: { 'content-type': contentType },
headers: {
'content-type': contentType,
...(ownerEntityUrl ? { 'electric-owner-entity': ownerEntityUrl } : {}),
},
})

if (!response.ok && response.status !== 409) {
Expand Down
8 changes: 8 additions & 0 deletions packages/agents-runtime/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,13 @@ export interface EntityCreated {
parent_url?: string
}

export type EntityTypePermissionGrantDefinition = {
subject_kind: `principal` | `principal_kind`
subject_value: string
permission: `spawn` | `manage`
expires_at?: string
}

export interface PendingSend {
targetUrl: string
payload: unknown
Expand Down Expand Up @@ -1047,6 +1054,7 @@ export interface EntityDefinition<
creationSchema?: TCreationSchema
inboxSchemas?: Record<string, StandardJSONSchemaV1>
stateSchemas?: Record<string, StandardJSONSchemaV1>
permissionGrants?: ReadonlyArray<EntityTypePermissionGrantDefinition>

handler: (
ctx: HandlerContext<
Expand Down
14 changes: 14 additions & 0 deletions packages/agents-runtime/test/create-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,13 @@ describe(`createRuntimeHandler`, () => {
defineEntity(`schema-agent`, {
description: `Schema agent`,
stateSchemas: { custom: makeStandardSchema({ type: `object` }) },
permissionGrants: [
{
subject_kind: `principal_kind`,
subject_value: `user`,
permission: `spawn`,
},
],
handler: async () => {},
})

Expand Down Expand Up @@ -698,6 +705,13 @@ describe(`createRuntimeHandler`, () => {
},
],
},
permission_grants: [
{
subject_kind: `principal_kind`,
subject_value: `user`,
permission: `spawn`,
},
],
state_schemas: expect.objectContaining({
custom: { type: `object` },
run: expect.any(Object),
Expand Down
4 changes: 2 additions & 2 deletions packages/agents-runtime/test/electric-agents-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ describe(`createAgentsClient`, () => {
})
expect(mockState.createStreamDB).toHaveBeenCalledWith({
streamOptions: {
url: `http://electric-agents.test${source.streamUrl}`,
url: `http://electric-agents.test/_entities/source-1`,
contentType: `application/json`,
},
state: source.schema,
Expand All @@ -117,7 +117,7 @@ describe(`createAgentsClient`, () => {

expect(mockState.createStreamDB).toHaveBeenCalledWith({
streamOptions: {
url: `http://electric-agents.test/t/tenant-a/v1${source.streamUrl}`,
url: `http://electric-agents.test/t/tenant-a/v1/_entities/source-1`,
contentType: `application/json`,
},
state: source.schema,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,26 @@ describe(`runtime-server-client.setTag`, () => {
)
})

it(`ensureSharedStateStream sends the owner entity header`, async () => {
const calls: Array<{ url: string; init?: RequestInit }> = []
const fakeFetch = vi.fn(async (url: string, init?: RequestInit) => {
calls.push({ url, init })
return new Response(null, { status: 201 })
}) as unknown as typeof fetch
const client = createRuntimeServerClient({
baseUrl: `http://test.example`,
fetch: fakeFetch,
})

await expect(
client.ensureSharedStateStream(`board-1`, `/task/owner`)
).resolves.toBe(`/_electric/shared-state/board-1`)

const headers = new Headers(calls[0]!.init?.headers)
expect(headers.get(`content-type`)).toBe(`application/json`)
expect(headers.get(`electric-owner-entity`)).toBe(`/task/owner`)
})

it(`sends POST with bearer token and tag body`, async () => {
const calls: Array<{ url: string; init?: RequestInit }> = []
const fakeFetch = vi.fn(async (url: string, init?: RequestInit) => {
Expand Down
1 change: 1 addition & 0 deletions packages/agents-server/docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ services:
environment:
DATABASE_URL: postgresql://electric_agents:electric_agents@postgres:5432/electric_agents
ELECTRIC_INSECURE: 'true'
ELECTRIC_FEATURE_FLAGS: allow_subqueries
depends_on:
postgres:
condition: service_healthy
Expand Down
100 changes: 100 additions & 0 deletions packages/agents-server/drizzle/0011_entity_permissions.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
CREATE TABLE "entity_type_permission_grants" (
"id" bigserial PRIMARY KEY NOT NULL,
"tenant_id" text DEFAULT 'default' NOT NULL,
"entity_type" text NOT NULL,
"permission" text NOT NULL,
"subject_kind" text NOT NULL,
"subject_value" text NOT NULL,
"created_by" text,
"expires_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chk_type_permission_grants_permission" CHECK ("entity_type_permission_grants"."permission" IN ('spawn', 'manage')),
CONSTRAINT "chk_type_permission_grants_subject_kind" CHECK ("entity_type_permission_grants"."subject_kind" IN ('principal', 'principal_kind'))
);
--> statement-breakpoint
CREATE TABLE "entity_lineage" (
"tenant_id" text DEFAULT 'default' NOT NULL,
"ancestor_url" text NOT NULL,
"descendant_url" text NOT NULL,
"depth" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "entity_lineage_pkey" PRIMARY KEY ("tenant_id", "ancestor_url", "descendant_url"),
CONSTRAINT "chk_entity_lineage_depth" CHECK ("entity_lineage"."depth" >= 0)
);
--> statement-breakpoint
CREATE TABLE "entity_permission_grants" (
"id" bigserial PRIMARY KEY NOT NULL,
"tenant_id" text DEFAULT 'default' NOT NULL,
"entity_url" text NOT NULL,
"permission" text NOT NULL,
"subject_kind" text NOT NULL,
"subject_value" text NOT NULL,
"propagation" text DEFAULT 'self' NOT NULL,
"copy_to_children" boolean DEFAULT false NOT NULL,
"created_by" text,
"expires_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chk_entity_permission_grants_permission" CHECK ("entity_permission_grants"."permission" IN ('read', 'write', 'delete', 'signal', 'fork', 'schedule', 'spawn', 'manage')),
CONSTRAINT "chk_entity_permission_grants_subject_kind" CHECK ("entity_permission_grants"."subject_kind" IN ('principal', 'principal_kind')),
CONSTRAINT "chk_entity_permission_grants_propagation" CHECK ("entity_permission_grants"."propagation" IN ('self', 'descendants'))
);
--> statement-breakpoint
CREATE TABLE "entity_effective_permissions" (
"id" bigserial PRIMARY KEY NOT NULL,
"tenant_id" text DEFAULT 'default' NOT NULL,
"entity_url" text NOT NULL,
"source_entity_url" text NOT NULL,
"source_grant_id" bigint NOT NULL,
"permission" text NOT NULL,
"subject_kind" text NOT NULL,
"subject_value" text NOT NULL,
"expires_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_entity_effective_permission" UNIQUE ("tenant_id", "entity_url", "source_grant_id"),
CONSTRAINT "chk_entity_effective_permissions_permission" CHECK ("entity_effective_permissions"."permission" IN ('read', 'write', 'delete', 'signal', 'fork', 'schedule', 'spawn', 'manage')),
CONSTRAINT "chk_entity_effective_permissions_subject_kind" CHECK ("entity_effective_permissions"."subject_kind" IN ('principal', 'principal_kind'))
);
--> statement-breakpoint
CREATE TABLE "shared_state_links" (
"tenant_id" text DEFAULT 'default' NOT NULL,
"shared_state_id" text NOT NULL,
"owner_entity_url" text NOT NULL,
"manifest_key" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "shared_state_links_pkey" PRIMARY KEY ("tenant_id", "owner_entity_url", "manifest_key")
);
--> statement-breakpoint
CREATE INDEX "idx_type_permission_grants_lookup" ON "entity_type_permission_grants" USING btree ("tenant_id", "entity_type", "permission", "subject_kind", "subject_value");
--> statement-breakpoint
CREATE INDEX "idx_type_permission_grants_expiry" ON "entity_type_permission_grants" USING btree ("tenant_id", "expires_at");
--> statement-breakpoint
CREATE INDEX "idx_entity_lineage_descendant" ON "entity_lineage" USING btree ("tenant_id", "descendant_url");
--> statement-breakpoint
CREATE INDEX "idx_entity_permission_grants_entity" ON "entity_permission_grants" USING btree ("tenant_id", "entity_url");
--> statement-breakpoint
CREATE INDEX "idx_entity_permission_grants_subject" ON "entity_permission_grants" USING btree ("tenant_id", "permission", "subject_kind", "subject_value");
--> statement-breakpoint
CREATE INDEX "idx_entity_permission_grants_expiry" ON "entity_permission_grants" USING btree ("tenant_id", "expires_at");
--> statement-breakpoint
CREATE INDEX "idx_entity_effective_permissions_lookup" ON "entity_effective_permissions" USING btree ("tenant_id", "permission", "subject_kind", "subject_value", "entity_url");
--> statement-breakpoint
CREATE INDEX "idx_entity_effective_permissions_entity" ON "entity_effective_permissions" USING btree ("tenant_id", "entity_url");
--> statement-breakpoint
CREATE INDEX "idx_entity_effective_permissions_expiry" ON "entity_effective_permissions" USING btree ("tenant_id", "expires_at");
--> statement-breakpoint
CREATE INDEX "idx_shared_state_links_shared_state" ON "shared_state_links" USING btree ("tenant_id", "shared_state_id");
--> statement-breakpoint
CREATE INDEX "idx_shared_state_links_owner" ON "shared_state_links" USING btree ("tenant_id", "owner_entity_url");
--> statement-breakpoint
-- Pre-permission entity bridge rows do not carry principal attribution. Drop them
-- so observation bridges are rebuilt with principal_url/principal_kind scoping.
DELETE FROM "entity_bridges";
--> statement-breakpoint
ALTER TABLE "entity_bridges" ADD COLUMN "principal_url" text;
--> statement-breakpoint
ALTER TABLE "entity_bridges" ADD COLUMN "principal_kind" text;
--> statement-breakpoint
CREATE INDEX "idx_entity_bridges_principal" ON "entity_bridges" USING btree ("tenant_id", "principal_kind", "principal_url");
7 changes: 7 additions & 0 deletions packages/agents-server/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@
"when": 1779062400000,
"tag": "0010_sandbox_profiles",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1779050000000,
"tag": "0011_entity_permissions",
"breakpoints": true
}
]
}
Loading
Loading