Skip to content

Commit d15852d

Browse files
authored
Fix agent send attribution auth (#4522)
## Summary - include both from_principal/from_agent plus the active wake write token on runtime-originated sends - allow server send attribution when from_agent is backed by a valid active agent write token - keep spoofed from_agent rejection and add coverage for the write-token path ## Tests - pnpm --filter @electric-ax/agents-mcp build - pnpm --filter @electric-sql/client build - pnpm --filter @electric-ax/agents-runtime build - cd packages/agents-server && pnpm vitest run test/electric-agents-routes.test.ts -t "send endpoint" - cd packages/agents-runtime && pnpm vitest run test/process-wake.test.ts -t "executes send via server API" - pnpm --filter @electric-ax/agents-runtime typecheck - pnpm --filter @electric-ax/agents-server typecheck
1 parent 24d2c34 commit d15852d

6 files changed

Lines changed: 119 additions & 6 deletions

File tree

‎.changeset/fix-agent-send-auth.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@electric-ax/agents-runtime": patch
3+
"@electric-ax/agents-server": patch
4+
---
5+
6+
Fix runtime-originated agent send attribution by sending `from_principal`, `from_agent`, and the active wake write token, and accepting `from_agent` when backed by a valid agent write token.

‎packages/agents-runtime/src/process-wake.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1227,7 +1227,9 @@ export async function processWake(
12271227
payload: send.payload,
12281228
type: send.type,
12291229
afterMs: send.afterMs,
1230+
fromPrincipal: notification.principal?.url,
12301231
fromAgent: entityUrl,
1232+
writeToken,
12311233
})
12321234
.then(() => ({ sent: true as const, targetUrl: send.targetUrl }))
12331235

‎packages/agents-runtime/src/runtime-server-client.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ export interface SendEntityMessageOptions {
8686
afterMs?: number
8787
mode?: `immediate` | `queued` | `paused` | `steer`
8888
position?: string
89+
fromPrincipal?: string
8990
fromAgent?: string
91+
writeToken?: string
9092
}
9193

9294
export interface RegisterWakeOptions {
@@ -290,18 +292,30 @@ export function createRuntimeServerClient(
290292
afterMs,
291293
mode,
292294
position,
295+
fromPrincipal,
293296
fromAgent,
297+
writeToken,
294298
}: SendEntityMessageOptions): Promise<void> => {
295299
const body: Record<string, unknown> = { payload }
296300
if (type !== undefined) body.type = type
297301
if (afterMs !== undefined) body.afterMs = afterMs
298302
if (mode !== undefined) body.mode = mode
299303
if (position !== undefined) body.position = position
304+
if (fromPrincipal !== undefined) body.from_principal = fromPrincipal
300305
if (fromAgent !== undefined) body.from_agent = fromAgent
301306

307+
const headers = new Headers({ 'content-type': `application/json` })
308+
if (writeToken) {
309+
applyTokenHeader(
310+
headers,
311+
config.writeTokenHeader ?? `authorization`,
312+
writeToken
313+
)
314+
}
315+
302316
const response = await request(`${entityRpcPath(targetUrl)}/send`, {
303317
method: `POST`,
304-
headers: { 'content-type': `application/json` },
318+
headers,
305319
body: JSON.stringify(body),
306320
})
307321

‎packages/agents-runtime/test/process-wake.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1350,6 +1350,16 @@ describe(`processWake`, () => {
13501350
},
13511351
})
13521352

1353+
fetchMock.mockResolvedValueOnce(
1354+
new Response(
1355+
JSON.stringify({ ok: true, writeToken: `mock-write-token` }),
1356+
{
1357+
status: 200,
1358+
headers: { 'content-type': `application/json` },
1359+
}
1360+
)
1361+
)
1362+
13531363
await processWake(
13541364
makeNotification({
13551365
principal: {
@@ -1368,10 +1378,14 @@ describe(`processWake`, () => {
13681378
expect(String(sendUrl)).toContain(`target-entity-2/send`)
13691379
const body = JSON.parse(sendOpts!.body as string) as Record<string, unknown>
13701380
expect(body.from).toBeUndefined()
1381+
expect(body.from_principal).toBe(`http://localhost:3000/test-agent/agent-1`)
13711382
expect(body.from_agent).toBe(`http://localhost:3000/test-agent/agent-1`)
13721383
expect((sendOpts!.headers as Headers).get(`electric-principal`)).toBe(
13731384
`entity:test-agent/agent-1`
13741385
)
1386+
expect((sendOpts!.headers as Headers).get(`authorization`)).toBe(
1387+
`Bearer mock-write-token`
1388+
)
13751389
expect((body.payload as Record<string, unknown>).action).toBe(`ping`)
13761390
})
13771391

‎packages/agents-server/src/routing/entities-router.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,19 @@ function agentUrlPath(value: string): string {
187187
}
188188
}
189189

190+
async function hasValidAgentWriteToken(
191+
request: AgentsRouteRequest,
192+
ctx: TenantContext,
193+
fromAgent: string
194+
): Promise<boolean> {
195+
const agentUrl = agentUrlPath(fromAgent)
196+
const token = writeTokenFromRequest(request)
197+
if (!token) return false
198+
const agentEntity = await ctx.entityManager.registry.getEntity(agentUrl)
199+
if (!agentEntity) return false
200+
return ctx.entityManager.isValidWriteToken(agentEntity, token)
201+
}
202+
190203
const inboxMessageBodySchema = Type.Object({
191204
payload: Type.Optional(Type.Unknown()),
192205
position: Type.Optional(Type.String()),
@@ -1138,7 +1151,14 @@ async function sendEntity(
11381151
}
11391152
if (parsed.from_agent !== undefined) {
11401153
const principalAgentUrl = agentUrlForPrincipal(principal)
1141-
if (agentUrlPath(parsed.from_agent) !== principalAgentUrl) {
1154+
const fromAgentUrl = agentUrlPath(parsed.from_agent)
1155+
const matchesPrincipalAgent = fromAgentUrl === principalAgentUrl
1156+
const hasAgentWriteToken = await hasValidAgentWriteToken(
1157+
request,
1158+
ctx,
1159+
parsed.from_agent
1160+
)
1161+
if (!matchesPrincipalAgent && !hasAgentWriteToken) {
11421162
return apiError(
11431163
400,
11441164
ErrCodeInvalidRequest,

‎packages/agents-server/test/electric-agents-routes.test.ts‎

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ function createRequest(
88
method: string,
99
path: string,
1010
body?: unknown,
11-
rawBody = false
11+
rawBody = false,
12+
headers?: HeadersInit
1213
): Request {
13-
const init: RequestInit = { method }
14+
const init: RequestInit = { method, headers }
1415
if (body !== undefined) {
1516
init.body = rawBody ? String(body) : JSON.stringify(body)
1617
}
@@ -28,10 +29,11 @@ async function routeResponse(
2829
id: `dev-local`,
2930
key: `system:dev-local`,
3031
url: `/principal/system:dev-local`,
31-
}
32+
},
33+
headers?: HeadersInit
3234
): Promise<Response> {
3335
const result = await globalRouter.fetch(
34-
createRequest(method, path, body, rawBody),
36+
createRequest(method, path, body, rawBody, headers),
3537
{
3638
service: `test`,
3739
entityManager: manager,
@@ -790,6 +792,61 @@ describe(`ElectricAgentsRoutes send endpoint`, () => {
790792
})
791793
})
792794

795+
it(`allows from_agent values with a valid active agent write token`, async () => {
796+
const targetEntity = { url: `/chat/test` }
797+
const agentEntity = { url: `/horton/current`, write_token: `entity-token` }
798+
const manager = {
799+
registry: {
800+
getEntity: vi.fn(async (url: string) => {
801+
if (url === `/chat/test`) return targetEntity
802+
if (url === `/horton/current`) return agentEntity
803+
return null
804+
}),
805+
getEntityType: vi.fn(),
806+
},
807+
isValidWriteToken: vi.fn(
808+
(entity, token) => entity === agentEntity && token === `claim-token`
809+
),
810+
ensurePrincipal: vi.fn().mockResolvedValue(undefined),
811+
send: vi.fn().mockResolvedValue(undefined),
812+
} as any
813+
814+
const response = await routeResponse(
815+
manager,
816+
`POST`,
817+
`/_electric/entities/chat/test/send`,
818+
{
819+
payload: { text: `hi` },
820+
from_principal: `/principal/system:dev-local`,
821+
from_agent: `/horton/current`,
822+
},
823+
false,
824+
{
825+
kind: `system`,
826+
id: `dev-local`,
827+
key: `system:dev-local`,
828+
url: `/principal/system:dev-local`,
829+
},
830+
{ 'electric-claim-token': `claim-token` }
831+
)
832+
833+
expect(response.status).toBe(204)
834+
expect(manager.isValidWriteToken).toHaveBeenCalledWith(
835+
agentEntity,
836+
`claim-token`
837+
)
838+
expect(manager.send).toHaveBeenCalledWith(`/chat/test`, {
839+
from: `/principal/system:dev-local`,
840+
from_principal: `/principal/system:dev-local`,
841+
from_agent: `/horton/current`,
842+
payload: { text: `hi` },
843+
key: undefined,
844+
type: undefined,
845+
mode: undefined,
846+
position: undefined,
847+
})
848+
})
849+
793850
it(`allows matching from_agent values for agent principals`, async () => {
794851
const manager = {
795852
registry: {

0 commit comments

Comments
 (0)