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. 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) }, }) 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