From 0f4dd7df32199081b8f26848e6c9720e207dbbf4 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 11 Jun 2026 10:51:02 -0600 Subject: [PATCH 1/3] fix: harden desktop send response parsing --- .../src/lib/ElectricAgentsProvider.tsx | 43 ++++++++++++++++--- .../src/lib/auth-fetch.test.ts | 36 ++++++++++++++++ .../agents-server-ui/src/lib/auth-fetch.ts | 17 ++++++-- .../agents-server-ui/src/lib/sendMessage.ts | 35 ++++++++++++--- 4 files changed, 115 insertions(+), 16 deletions(-) diff --git a/packages/agents-server-ui/src/lib/ElectricAgentsProvider.tsx b/packages/agents-server-ui/src/lib/ElectricAgentsProvider.tsx index 7b745ad582..b43d80aecd 100644 --- a/packages/agents-server-ui/src/lib/ElectricAgentsProvider.tsx +++ b/packages/agents-server-ui/src/lib/ElectricAgentsProvider.tsx @@ -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(res: Response): Promise { + 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(res: Response): Promise { + const data = await readOptionalJson(res) + if (data === null) { + throw new Error(`Response body was empty`) + } + return data +} + function showSignalFailureToast(input: { action: `kill` | `signal` entityUrl: string @@ -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) { @@ -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 } }, @@ -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) { @@ -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 } }, @@ -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 }) diff --git a/packages/agents-server-ui/src/lib/auth-fetch.test.ts b/packages/agents-server-ui/src/lib/auth-fetch.test.ts index c661687add..3109443af9 100644 --- a/packages/agents-server-ui/src/lib/auth-fetch.test.ts +++ b/packages/agents-server-ui/src/lib/auth-fetch.test.ts @@ -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`, diff --git a/packages/agents-server-ui/src/lib/auth-fetch.ts b/packages/agents-server-ui/src/lib/auth-fetch.ts index b818a144af..cf9416516a 100644 --- a/packages/agents-server-ui/src/lib/auth-fetch.ts +++ b/packages/agents-server-ui/src/lib/auth-fetch.ts @@ -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) @@ -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 }) } diff --git a/packages/agents-server-ui/src/lib/sendMessage.ts b/packages/agents-server-ui/src/lib/sendMessage.ts index 500e3da70a..96b28c7b05 100644 --- a/packages/agents-server-ui/src/lib/sendMessage.ts +++ b/packages/agents-server-ui/src/lib/sendMessage.ts @@ -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(res: Response): Promise { + 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(res: Response): Promise { + const data = await readOptionalJson(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) { @@ -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`) @@ -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) { @@ -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) }, }) @@ -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) }, }) @@ -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) }, }) @@ -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) }, }) From d138be412026bc25b444fc9f52b586fb75503cb9 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 11 Jun 2026 10:53:36 -0600 Subject: [PATCH 2/3] chore: drop isolated .env preflight check --- scripts/dev.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/dev.sh b/scripts/dev.sh index b5c90cb3ef..e7e58b0427 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -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 From 23fc2151357dc849a1591cd07a669e2cf3cc6547 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 11 Jun 2026 10:58:52 -0600 Subject: [PATCH 3/3] chore: add changeset for agents-server-ui --- .changeset/fix-desktop-send-response-parsing.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-desktop-send-response-parsing.md diff --git a/.changeset/fix-desktop-send-response-parsing.md b/.changeset/fix-desktop-send-response-parsing.md new file mode 100644 index 0000000000..d9c109a7f3 --- /dev/null +++ b/.changeset/fix-desktop-send-response-parsing.md @@ -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.