Skip to content

Commit a600b79

Browse files
authored
Fix Agents send principal attribution (#4604)
Fixes Electric Agents sends from the CLI and web UI by removing client-supplied legacy `from` attribution. CLI requests now always include a useful default `Electric-Principal` header (`system:cli-<os-username>`), so the server can keep deriving sender attribution from the authenticated principal. ## Root Cause The Agents send endpoint validates that any request body `from` value matches the authenticated `Electric-Principal`. Current clients could still send stale/display-level attribution (`from: "user"` in the UI repro, and the CLI's identity string), which does not match the server principal and produced `400 Request from must match Electric-Principal`. ## Approach - Stop sending `from` in `electric-ax agents send`; the request body now contains only `payload` and optional `type`. - Give the CLI a default principal header of `system:cli-<os-username>` when `ELECTRIC_AGENTS_PRINCIPAL` is not set. - Stop the web UI send helper from resolving and posting a `from` value; optimistic local rendering still uses the active principal locally, while server writes are attributed by the server. - Keep server validation strict instead of adding backward-compatible aliases. ## Key Invariants - Request body `from`, when present, must match the authenticated principal. - First-party clients should not post `from` for normal user sends. - Server-side attribution remains the source of truth for persisted inbox messages. ## Non-goals - No compatibility shim for old clients that still send `from: "user"`. - No changes to server-side authorization or principal validation semantics. ## Trade-offs I initially considered accepting `from: "user"` server-side as a compatibility alias, but that weakens the clarity of the send invariant. Since backwards compatibility is not required here, updating the current clients keeps the contract simpler. ## Verification ```bash cd packages/electric-ax pnpm exec vitest run test/cli.test.ts cd ../agents-server-ui pnpm run typecheck ``` Also ran the changeset checker: ```bash GITHUB_BASE_REF=main node scripts/check-changeset.mjs ``` ## Files changed - `.changeset/fix-cli-principal-send.md` — patch changeset for `electric-ax` and `@electric-ax/agents-server-ui`. - `packages/electric-ax/src/index.ts` — default CLI principal header and no `from` field in sends. - `packages/electric-ax/test/cli.test.ts` — coverage for default CLI principal and send request body. - `packages/agents-server-ui/src/lib/sendMessage.ts` — remove send-body principal resolution and `from` posting. --- Fixes #4602
1 parent e6171a9 commit a600b79

4 files changed

Lines changed: 54 additions & 52 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'electric-ax': patch
3+
'@electric-ax/agents-server-ui': patch
4+
---
5+
6+
Stop CLI and web UI sends from posting legacy `from` attribution and derive message senders from the authenticated Electric principal instead. The CLI now sends a default `Electric-Principal` header of `system:cli-<os-username>` when no explicit principal is configured.

packages/agents-server-ui/src/lib/sendMessage.ts

Lines changed: 1 addition & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,8 @@ import {
44
COMPOSER_INPUT_MESSAGE_TYPE,
55
createPendingTimelineOrder,
66
} from '@electric-ax/agents-runtime/client'
7-
import {
8-
getActivePrincipal,
9-
getConfiguredActivePrincipal,
10-
getConfiguredServerHeaders,
11-
serverFetch,
12-
} from './auth-fetch'
7+
import { getActivePrincipal, serverFetch } from './auth-fetch'
138
import { entityApiUrl } from './entity-api'
14-
import { loadCloudAuthState } from './server-connection'
159
import type { EntityStreamDBWithActions } from '@electric-ax/agents-runtime/client'
1610
import type { ComposerInputPayload } from '@electric-ax/agents-runtime/client'
1711

@@ -309,7 +303,6 @@ export async function sendEntityMessage({
309303
mode = `queued`,
310304
position,
311305
attachments,
312-
from,
313306
}: {
314307
baseUrl: string
315308
entityUrl: string
@@ -320,13 +313,8 @@ export async function sendEntityMessage({
320313
mode?: `immediate` | `queued` | `paused` | `steer`
321314
position?: string
322315
attachments?: Array<AttachmentInput>
323-
from?: string
324316
}): Promise<{ txid: string; attachmentTxids: Array<string> }> {
325317
const url = entityApiUrl(baseUrl, entityUrl, `/send`)
326-
const sender = await resolveSenderPrincipalUrl(
327-
url,
328-
from ?? getConfiguredActivePrincipal() ?? ``
329-
)
330318
const uploadedAttachments = await uploadMessageAttachments({
331319
baseUrl,
332320
entityUrl,
@@ -339,7 +327,6 @@ export async function sendEntityMessage({
339327
method: `POST`,
340328
headers: { 'content-type': `application/json` },
341329
body: JSON.stringify({
342-
from: sender,
343330
key,
344331
payload: effectivePayload,
345332
mode,
@@ -381,33 +368,6 @@ export function readTextPayload(payload: unknown): string {
381368
return ``
382369
}
383370

384-
function principalUrl(principalKey: string): string {
385-
return `/principal/${encodeURIComponent(principalKey)}`
386-
}
387-
388-
function principalUrlFromConfiguredHeaders(url: string): string | null {
389-
const headers = new Headers(getConfiguredServerHeaders(url))
390-
const principal = headers.get(`electric-principal`)?.trim()
391-
return principal ? principalUrl(principal) : null
392-
}
393-
394-
async function resolveSenderPrincipalUrl(
395-
url: string,
396-
from: string
397-
): Promise<string> {
398-
if (from.startsWith(`/principal/`)) return from
399-
400-
const headerPrincipal = principalUrlFromConfiguredHeaders(url)
401-
if (headerPrincipal) return headerPrincipal
402-
403-
const cloudAuth = await loadCloudAuthState().catch(() => null)
404-
if (cloudAuth?.status === `signed-in` && cloudAuth.userId) {
405-
return principalUrl(`user:${cloudAuth.userId}`)
406-
}
407-
408-
return principalUrl(`system:dev-local`)
409-
}
410-
411371
export function createSendMessageAction({
412372
db,
413373
baseUrl,
@@ -455,7 +415,6 @@ export function createSendMessageAction({
455415
mode,
456416
position,
457417
attachments,
458-
from,
459418
})
460419
await Promise.all([
461420
...attachmentTxids.map((id) => db.utils.awaitTxId(id, 10_000)),
@@ -464,15 +423,10 @@ export function createSendMessageAction({
464423
return
465424
}
466425
const url = entityApiUrl(baseUrl, entityUrl, `/send`)
467-
const sender = await resolveSenderPrincipalUrl(
468-
url,
469-
from ?? getConfiguredActivePrincipal() ?? ``
470-
)
471426
const res = await serverFetch(url, {
472427
method: `POST`,
473428
headers: { 'content-type': `application/json` },
474429
body: JSON.stringify({
475-
from: sender,
476430
key,
477431
payload,
478432
mode,

packages/electric-ax/src/index.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ function getDefaultElectricAgentsIdentity(): string {
134134
return `${userInfo().username}@${hostname()}`
135135
}
136136

137+
function getDefaultElectricAgentsPrincipal(): string {
138+
return `system:cli-${userInfo().username}`
139+
}
140+
137141
function parseElectricAgentsHeaders(
138142
raw: string | undefined
139143
): Record<string, string> | undefined {
@@ -167,14 +171,15 @@ export function getElectricCliEnv(
167171
env: NodeJS.ProcessEnv = process.env
168172
): ElectricCliEnv {
169173
const explicitIdentity = env.ELECTRIC_AGENTS_IDENTITY?.trim()
174+
const explicitPrincipal = env.ELECTRIC_AGENTS_PRINCIPAL?.trim()
170175
const headers = parseElectricAgentsHeaders(env.ELECTRIC_AGENTS_SERVER_HEADERS)
171176
return {
172177
electricAgentsUrl: env.ELECTRIC_AGENTS_URL || DEFAULT_ELECTRIC_AGENTS_URL,
173178
electricAgentsIdentity:
174179
explicitIdentity || getDefaultElectricAgentsIdentity(),
175180
electricAgentsHeaders: mergeElectricPrincipalHeader(
176181
headers,
177-
env.ELECTRIC_AGENTS_PRINCIPAL
182+
explicitPrincipal || getDefaultElectricAgentsPrincipal()
178183
),
179184
}
180185
}
@@ -571,7 +576,6 @@ async function sendMessage(
571576
const payload = parsePayload(message, options.json ?? false)
572577

573578
const body: Record<string, unknown> = {
574-
from: env.electricAgentsIdentity,
575579
payload,
576580
}
577581
if (options.type) {
@@ -825,7 +829,7 @@ function getHelpText(commandName: string): string {
825829
Environment:
826830
ELECTRIC_AGENTS_URL Base URL of the server (default: ${DEFAULT_ELECTRIC_AGENTS_URL})
827831
ELECTRIC_AGENTS_IDENTITY Sender identity for messages (default: ${getDefaultElectricAgentsIdentity()})
828-
ELECTRIC_AGENTS_PRINCIPAL Optional principal key sent as the Electric-Principal header
832+
ELECTRIC_AGENTS_PRINCIPAL Principal key sent as the Electric-Principal header (default: ${getDefaultElectricAgentsPrincipal()})
829833
ELECTRIC_AGENTS_SERVER_HEADERS Optional JSON object of additional server headers
830834
ANTHROPIC_API_KEY Required for '${agentsCommand} start-builtin' and '${agentsCommand} quickstart'
831835

packages/electric-ax/test/cli.test.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it, vi } from 'vitest'
22
import { mkdtempSync } from 'node:fs'
3-
import { tmpdir } from 'node:os'
3+
import { tmpdir, userInfo } from 'node:os'
44
import { join } from 'node:path'
55
import {
66
createElectricCliHandlers,
@@ -132,7 +132,18 @@ async function parse(argv: Array<string>, handlers = createHandlers()) {
132132
}
133133

134134
describe(`createElectricProgram`, () => {
135-
it(`adds an optional principal header from the environment`, () => {
135+
it(`uses system cli username as the default principal header`, () => {
136+
const env = getElectricCliEnv({
137+
ELECTRIC_AGENTS_URL: `https://agents.example.test`,
138+
ELECTRIC_AGENTS_IDENTITY: `tester@example.com`,
139+
})
140+
141+
expect(env.electricAgentsHeaders).toEqual({
142+
'electric-principal': `system:cli-${userInfo().username}`,
143+
})
144+
})
145+
146+
it(`allows the principal header to be overridden from the environment`, () => {
136147
const env = getElectricCliEnv({
137148
ELECTRIC_AGENTS_URL: `https://agents.example.test`,
138149
ELECTRIC_AGENTS_IDENTITY: `tester@example.com`,
@@ -351,6 +362,33 @@ describe(`createElectricProgram`, () => {
351362
)
352363
})
353364

365+
it(`sends messages without legacy from attribution`, async () => {
366+
const fetchMock = vi.spyOn(globalThis, `fetch`).mockResolvedValue(
367+
new Response(JSON.stringify({ txid: `123` }), {
368+
status: 200,
369+
headers: { 'content-type': `application/json` },
370+
})
371+
)
372+
373+
try {
374+
await createElectricCliHandlers(TEST_ENV).send(`/chat/test`, `hello`, {
375+
type: `chat_message`,
376+
})
377+
expect(fetchMock).toHaveBeenCalledWith(
378+
`http://localhost:4437/_electric/entities/chat/test/send`,
379+
expect.objectContaining({
380+
method: `POST`,
381+
body: JSON.stringify({
382+
payload: { text: `hello` },
383+
type: `chat_message`,
384+
}),
385+
})
386+
)
387+
} finally {
388+
fetchMock.mockRestore()
389+
}
390+
})
391+
354392
it(`sends signal requests to the entity signal endpoint`, async () => {
355393
const fetchMock = vi.spyOn(globalThis, `fetch`).mockResolvedValue(
356394
new Response(JSON.stringify({ txid: 123 }), {

0 commit comments

Comments
 (0)