Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-desktop-send-response-parsing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@electric-ax/agents-server-ui': patch
---

Harden desktop UI response parsing for send and session mutation requests, and preserve configured auth headers when Electron local requests fall back from the desktop IPC transport to browser fetch.
43 changes: 38 additions & 5 deletions packages/agents-server-ui/src/lib/ElectricAgentsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,27 @@ function compactToastText(text: string): string {
return trimmed.length > 360 ? `${trimmed.slice(0, 357)}...` : trimmed
}

const NULL_BODY_STATUSES = new Set([204, 205, 304])

async function readOptionalJson<T>(res: Response): Promise<T | null> {
if (NULL_BODY_STATUSES.has(res.status)) return null
const raw = await res.text().catch(() => ``)
if (!raw.trim()) return null
try {
return JSON.parse(raw) as T
} catch {
throw new Error(`Response body was not valid JSON`)
}
}

async function readRequiredJson<T>(res: Response): Promise<T> {
const data = await readOptionalJson<T>(res)
if (data === null) {
throw new Error(`Response body was empty`)
}
return data
}

function showSignalFailureToast(input: {
action: `kill` | `signal`
entityUrl: string
Expand Down Expand Up @@ -557,7 +578,10 @@ function createSpawnAction(
}
throw new Error(message)
}
const data = (await res.json()) as { txid: number }
const data = await readRequiredJson<{ txid?: number }>(res)
if (typeof data.txid !== `number`) {
throw new Error(`Spawn returned an invalid txid response`)
}
await entitiesCollection.utils.awaitTxId(data.txid, 10_000)
return { txid: data.txid }
} catch (err) {
Expand Down Expand Up @@ -614,7 +638,10 @@ function createKillAction(
})
throw new Error(text || `Kill failed (${res.status})`)
}
const data = (await res.json()) as { txid: number }
const data = await readRequiredJson<{ txid?: number }>(res)
if (typeof data.txid !== `number`) {
throw new Error(`Kill returned an invalid txid response`)
}
await entitiesCollection.utils.awaitTxId(data.txid, 10_000)
return { txid: data.txid }
},
Expand Down Expand Up @@ -671,7 +698,10 @@ function createSetEntityTitleAction(
parseErrorResponse(text) || `Set title failed (${res.status})`
)
}
const data = (await res.json()) as { txid: number }
const data = await readRequiredJson<{ txid?: number }>(res)
if (typeof data.txid !== `number`) {
throw new Error(`Set title returned an invalid txid response`)
}
await entitiesCollection.utils.awaitTxId(data.txid, 10_000)
return { txid: data.txid }
} catch (err) {
Expand Down Expand Up @@ -733,7 +763,10 @@ function createSignalAction(
})
throw new Error(text || `Signal failed (${res.status})`)
}
const data = (await res.json()) as { txid: number }
const data = await readRequiredJson<{ txid?: number }>(res)
if (typeof data.txid !== `number`) {
throw new Error(`Signal returned an invalid txid response`)
}
await entitiesCollection.utils.awaitTxId(data.txid, 10_000)
return { txid: data.txid }
},
Expand Down Expand Up @@ -776,7 +809,7 @@ function createForkEntity(baseUrl: string) {
const message = parseErrorResponse(text) ?? `Fork failed (${res.status})`
throw new Error(message)
}
const data = (await res.json()) as { root?: { url?: string } }
const data = await readRequiredJson<{ root?: { url?: string } }>(res)
if (!data.root?.url) {
const message = `Fork returned an invalid response`
showForkFailureToast({ entityUrl, error: message })
Expand Down
36 changes: 36 additions & 0 deletions packages/agents-server-ui/src/lib/auth-fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,42 @@ describe(`server fetch helpers`, () => {
expect(fetchMock).toHaveBeenCalledOnce()
})

it(`injects configured headers when a local Electron request falls back from desktop transport`, async () => {
const desktopFetch = vi.fn()
;(globalThis as { window?: unknown }).window = {
electronAPI: { serverFetch: desktopFetch },
}
registerActiveServerHeaders({
name: `Local`,
url: `http://127.0.0.1:4437`,
headers: {
'electric-principal': `system:dev-local`,
Authorization: `Bearer local-token`,
},
})

const fetchMock = vi
.spyOn(globalThis, `fetch`)
.mockResolvedValue(new Response(`ok`))

const form = new FormData()
form.set(`file`, new Blob([`hi`], { type: `text/plain` }), `hi.txt`)

await serverFetch(
`http://127.0.0.1:4437/_electric/entities/horton/a/attachments`,
{
method: `POST`,
body: form,
}
)

expect(desktopFetch).not.toHaveBeenCalled()
expect(fetchMock).toHaveBeenCalledOnce()
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers)
expect(headers.get(`electric-principal`)).toBe(`system:dev-local`)
expect(headers.get(`authorization`)).toBe(`Bearer local-token`)
})

it(`returns the active principal as a canonical principal URL`, () => {
registerActiveServerHeaders({
name: `Tenant`,
Expand Down
17 changes: 13 additions & 4 deletions packages/agents-server-ui/src/lib/auth-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,13 +226,17 @@ export async function serverFetch(
new Headers(init.headers).forEach((value, key) => {
headers.set(key, value)
})
if (!hasDesktopHeaderInjection()) {
for (const [key, value] of Object.entries(
getConfiguredServerHeaders(input)
)) {

const configuredHeaders = getConfiguredServerHeaders(input)
const applyConfiguredHeaders = (): void => {
for (const [key, value] of Object.entries(configuredHeaders)) {
if (!headers.has(key)) headers.set(key, value)
}
}

if (!hasDesktopHeaderInjection()) {
applyConfiguredHeaders()
}
if (shouldUseDesktopServerFetch(input, init)) {
const api = desktopServerFetchApi()
const url = urlFromInput(input)
Expand All @@ -247,6 +251,11 @@ export async function serverFetch(
})
)
}
// Some request bodies (notably FormData uploads) cannot be serialized over
// the Electron IPC transport. When we fall back to the browser fetch path,
// explicitly inject the configured server headers here so auth remains
// intact instead of relying only on the global Electron header hook.
applyConfiguredHeaders()
}
return fetch(input, { ...init, headers })
}
35 changes: 28 additions & 7 deletions packages/agents-server-ui/src/lib/sendMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,27 @@ function readRequiredTxid(data: { txid?: unknown }, label: string): string {
return data.txid
}

const NULL_BODY_STATUSES = new Set([204, 205, 304])

async function readOptionalJson<T>(res: Response): Promise<T | null> {
if (NULL_BODY_STATUSES.has(res.status)) return null
const raw = await res.text().catch(() => ``)
if (!raw.trim()) return null
try {
return JSON.parse(raw) as T
} catch {
throw new Error(`Response body was not valid JSON`)
}
}

async function readRequiredJson<T>(res: Response): Promise<T> {
const data = await readOptionalJson<T>(res)
if (data === null) {
throw new Error(`Response body was empty`)
}
return data
}

function readSendError(status: number, body: string): Error {
let message = `Send failed (${status})`
if (body) {
Expand Down Expand Up @@ -237,10 +258,10 @@ export async function uploadMessageAttachments({
const body = await res.text().catch(() => ``)
throw readSendError(res.status, body)
}
const data = (await res.json()) as {
const data = await readRequiredJson<{
txid?: unknown
attachment?: { id?: unknown }
}
}>(res)
txids.push(readRequiredTxid(data, `Attachment upload`))
if (data.attachment?.id !== id) {
throw new Error(`Attachment upload returned an invalid response`)
Expand Down Expand Up @@ -330,7 +351,7 @@ export async function sendEntityMessage({
const body = await res.text().catch(() => ``)
throw readSendError(res.status, body)
}
const data = (await res.json()) as { txid?: unknown }
const data = await readRequiredJson<{ txid?: unknown }>(res)
const txid = readRequiredTxid(data, `Send`)
return { txid, attachmentTxids: uploadedAttachments.txids }
} catch (error) {
Expand Down Expand Up @@ -463,7 +484,7 @@ export function createSendMessageAction({
const body = await res.text().catch(() => ``)
throw readSendError(res.status, body)
}
const data = (await res.json()) as { txid?: unknown }
const data = await readRequiredJson<{ txid?: unknown }>(res)
await db.utils.awaitTxId(readRequiredTxid(data, `Send`), 10_000)
},
})
Expand Down Expand Up @@ -584,7 +605,7 @@ export function createUpdateInboxMessageAction({
const body = await res.text().catch(() => ``)
throw readSendError(res.status, body)
}
const data = (await res.json()) as { txid?: unknown }
const data = await readRequiredJson<{ txid?: unknown }>(res)
await db.utils.awaitTxId(readRequiredTxid(data, `Inbox update`), 10_000)
},
})
Expand Down Expand Up @@ -612,7 +633,7 @@ export function createDeleteInboxMessageAction({
const body = await res.text().catch(() => ``)
throw readSendError(res.status, body)
}
const data = (await res.json()) as { txid?: unknown }
const data = await readRequiredJson<{ txid?: unknown }>(res)
await db.utils.awaitTxId(readRequiredTxid(data, `Inbox delete`), 10_000)
},
})
Expand Down Expand Up @@ -649,7 +670,7 @@ export function createSteerInboxMessageAction({
const body = await res.text().catch(() => ``)
throw readSendError(res.status, body)
}
const data = (await res.json()) as { txid?: unknown }
const data = await readRequiredJson<{ txid?: unknown }>(res)
await db.utils.awaitTxId(readRequiredTxid(data, `Inbox steer`), 10_000)
},
})
Expand Down
2 changes: 0 additions & 2 deletions scripts/dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -373,8 +373,6 @@ cmd_isolated() {
done

cd "$REPO_ROOT"
[[ -f .env ]] || die ".env not found at repo root. Create one with ANTHROPIC_API_KEY or OPENAI_API_KEY."
grep -qE '^(ANTHROPIC_API_KEY|OPENAI_API_KEY)=' .env || die ".env is missing ANTHROPIC_API_KEY or OPENAI_API_KEY."
docker info >/dev/null 2>&1 || die "Docker daemon not reachable. Start Docker Desktop and retry."

if $do_build; then
Expand Down
Loading