diff --git a/.changeset/fix-cli-principal-send.md b/.changeset/fix-cli-principal-send.md new file mode 100644 index 0000000000..9ff4570361 --- /dev/null +++ b/.changeset/fix-cli-principal-send.md @@ -0,0 +1,6 @@ +--- +'electric-ax': patch +'@electric-ax/agents-server-ui': patch +--- + +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-` when no explicit principal is configured. diff --git a/packages/agents-server-ui/src/lib/sendMessage.ts b/packages/agents-server-ui/src/lib/sendMessage.ts index 96b28c7b05..5331b8f256 100644 --- a/packages/agents-server-ui/src/lib/sendMessage.ts +++ b/packages/agents-server-ui/src/lib/sendMessage.ts @@ -4,14 +4,8 @@ import { COMPOSER_INPUT_MESSAGE_TYPE, createPendingTimelineOrder, } from '@electric-ax/agents-runtime/client' -import { - getActivePrincipal, - getConfiguredActivePrincipal, - getConfiguredServerHeaders, - serverFetch, -} from './auth-fetch' +import { getActivePrincipal, serverFetch } from './auth-fetch' import { entityApiUrl } from './entity-api' -import { loadCloudAuthState } from './server-connection' import type { EntityStreamDBWithActions } from '@electric-ax/agents-runtime/client' import type { ComposerInputPayload } from '@electric-ax/agents-runtime/client' @@ -309,7 +303,6 @@ export async function sendEntityMessage({ mode = `queued`, position, attachments, - from, }: { baseUrl: string entityUrl: string @@ -320,13 +313,8 @@ export async function sendEntityMessage({ mode?: `immediate` | `queued` | `paused` | `steer` position?: string attachments?: Array - from?: string }): Promise<{ txid: string; attachmentTxids: Array }> { const url = entityApiUrl(baseUrl, entityUrl, `/send`) - const sender = await resolveSenderPrincipalUrl( - url, - from ?? getConfiguredActivePrincipal() ?? `` - ) const uploadedAttachments = await uploadMessageAttachments({ baseUrl, entityUrl, @@ -339,7 +327,6 @@ export async function sendEntityMessage({ method: `POST`, headers: { 'content-type': `application/json` }, body: JSON.stringify({ - from: sender, key, payload: effectivePayload, mode, @@ -381,33 +368,6 @@ export function readTextPayload(payload: unknown): string { return `` } -function principalUrl(principalKey: string): string { - return `/principal/${encodeURIComponent(principalKey)}` -} - -function principalUrlFromConfiguredHeaders(url: string): string | null { - const headers = new Headers(getConfiguredServerHeaders(url)) - const principal = headers.get(`electric-principal`)?.trim() - return principal ? principalUrl(principal) : null -} - -async function resolveSenderPrincipalUrl( - url: string, - from: string -): Promise { - if (from.startsWith(`/principal/`)) return from - - const headerPrincipal = principalUrlFromConfiguredHeaders(url) - if (headerPrincipal) return headerPrincipal - - const cloudAuth = await loadCloudAuthState().catch(() => null) - if (cloudAuth?.status === `signed-in` && cloudAuth.userId) { - return principalUrl(`user:${cloudAuth.userId}`) - } - - return principalUrl(`system:dev-local`) -} - export function createSendMessageAction({ db, baseUrl, @@ -455,7 +415,6 @@ export function createSendMessageAction({ mode, position, attachments, - from, }) await Promise.all([ ...attachmentTxids.map((id) => db.utils.awaitTxId(id, 10_000)), @@ -464,15 +423,10 @@ export function createSendMessageAction({ return } const url = entityApiUrl(baseUrl, entityUrl, `/send`) - const sender = await resolveSenderPrincipalUrl( - url, - from ?? getConfiguredActivePrincipal() ?? `` - ) const res = await serverFetch(url, { method: `POST`, headers: { 'content-type': `application/json` }, body: JSON.stringify({ - from: sender, key, payload, mode, diff --git a/packages/electric-ax/src/index.ts b/packages/electric-ax/src/index.ts index 96097710d3..8baa16ef62 100644 --- a/packages/electric-ax/src/index.ts +++ b/packages/electric-ax/src/index.ts @@ -134,6 +134,10 @@ function getDefaultElectricAgentsIdentity(): string { return `${userInfo().username}@${hostname()}` } +function getDefaultElectricAgentsPrincipal(): string { + return `system:cli-${userInfo().username}` +} + function parseElectricAgentsHeaders( raw: string | undefined ): Record | undefined { @@ -167,6 +171,7 @@ export function getElectricCliEnv( env: NodeJS.ProcessEnv = process.env ): ElectricCliEnv { const explicitIdentity = env.ELECTRIC_AGENTS_IDENTITY?.trim() + const explicitPrincipal = env.ELECTRIC_AGENTS_PRINCIPAL?.trim() const headers = parseElectricAgentsHeaders(env.ELECTRIC_AGENTS_SERVER_HEADERS) return { electricAgentsUrl: env.ELECTRIC_AGENTS_URL || DEFAULT_ELECTRIC_AGENTS_URL, @@ -174,7 +179,7 @@ export function getElectricCliEnv( explicitIdentity || getDefaultElectricAgentsIdentity(), electricAgentsHeaders: mergeElectricPrincipalHeader( headers, - env.ELECTRIC_AGENTS_PRINCIPAL + explicitPrincipal || getDefaultElectricAgentsPrincipal() ), } } @@ -571,7 +576,6 @@ async function sendMessage( const payload = parsePayload(message, options.json ?? false) const body: Record = { - from: env.electricAgentsIdentity, payload, } if (options.type) { @@ -825,7 +829,7 @@ function getHelpText(commandName: string): string { Environment: ELECTRIC_AGENTS_URL Base URL of the server (default: ${DEFAULT_ELECTRIC_AGENTS_URL}) ELECTRIC_AGENTS_IDENTITY Sender identity for messages (default: ${getDefaultElectricAgentsIdentity()}) - ELECTRIC_AGENTS_PRINCIPAL Optional principal key sent as the Electric-Principal header + ELECTRIC_AGENTS_PRINCIPAL Principal key sent as the Electric-Principal header (default: ${getDefaultElectricAgentsPrincipal()}) ELECTRIC_AGENTS_SERVER_HEADERS Optional JSON object of additional server headers ANTHROPIC_API_KEY Required for '${agentsCommand} start-builtin' and '${agentsCommand} quickstart' diff --git a/packages/electric-ax/test/cli.test.ts b/packages/electric-ax/test/cli.test.ts index 98910cc79a..ec7d89f40f 100644 --- a/packages/electric-ax/test/cli.test.ts +++ b/packages/electric-ax/test/cli.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { mkdtempSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { tmpdir, userInfo } from 'node:os' import { join } from 'node:path' import { createElectricCliHandlers, @@ -132,7 +132,18 @@ async function parse(argv: Array, handlers = createHandlers()) { } describe(`createElectricProgram`, () => { - it(`adds an optional principal header from the environment`, () => { + it(`uses system cli username as the default principal header`, () => { + const env = getElectricCliEnv({ + ELECTRIC_AGENTS_URL: `https://agents.example.test`, + ELECTRIC_AGENTS_IDENTITY: `tester@example.com`, + }) + + expect(env.electricAgentsHeaders).toEqual({ + 'electric-principal': `system:cli-${userInfo().username}`, + }) + }) + + it(`allows the principal header to be overridden from the environment`, () => { const env = getElectricCliEnv({ ELECTRIC_AGENTS_URL: `https://agents.example.test`, ELECTRIC_AGENTS_IDENTITY: `tester@example.com`, @@ -351,6 +362,33 @@ describe(`createElectricProgram`, () => { ) }) + it(`sends messages without legacy from attribution`, async () => { + const fetchMock = vi.spyOn(globalThis, `fetch`).mockResolvedValue( + new Response(JSON.stringify({ txid: `123` }), { + status: 200, + headers: { 'content-type': `application/json` }, + }) + ) + + try { + await createElectricCliHandlers(TEST_ENV).send(`/chat/test`, `hello`, { + type: `chat_message`, + }) + expect(fetchMock).toHaveBeenCalledWith( + `http://localhost:4437/_electric/entities/chat/test/send`, + expect.objectContaining({ + method: `POST`, + body: JSON.stringify({ + payload: { text: `hello` }, + type: `chat_message`, + }), + }) + ) + } finally { + fetchMock.mockRestore() + } + }) + it(`sends signal requests to the entity signal endpoint`, async () => { const fetchMock = vi.spyOn(globalThis, `fetch`).mockResolvedValue( new Response(JSON.stringify({ txid: 123 }), {