diff --git a/apps/desktop/src/main/acp-host.mjs b/apps/desktop/src/main/acp-host.mjs index af3e7fd4..c1822012 100644 --- a/apps/desktop/src/main/acp-host.mjs +++ b/apps/desktop/src/main/acp-host.mjs @@ -4,6 +4,7 @@ import { accessSync, constants as fsConstants, existsSync, readFileSync, realpat import { mkdtemp, rm } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import path from "node:path"; +import { validateTrustedAcpPath } from "./acp-path-store.mjs"; import { Readable, Writable } from "node:stream"; import { fileURLToPath } from "node:url"; import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, RequestError } from "@agentclientprotocol/sdk"; @@ -865,12 +866,14 @@ export function createAcpHostRuntime(options = {}) { }; return { - listAdapters() { + clearAdapterStatus(id) { latestStatus.delete(id); }, + listAdapters(configuredPaths = {}) { return ["codex", "claudeCode", "antigravity", "openClaw", "hermesAgent", "grokBuild", "deepseekHarness", "piAgent", "workbuddyCn", "workbuddyIntl"].map((id) => { if (installingIds.has(id)) return { ...adapterShell(id), state: "installing" }; - const resolved = resolveCommand({ id }, { prepareProxy: false }); + const customPath = configuredPaths[id]; + const resolved = resolveCommand({ id, path: customPath }, { prepareProxy: false }); return resolved.ok - ? { ...adapterShell(id), ...(latestStatus.get(id) ?? { state: "not_probed", detail: "not_probed" }), ...(resolved.version ? { version: resolved.version, managed: true } : {}), ...(updateFailures.has(id) ? { updateError: updateFailures.get(id) } : {}) } + ? { ...adapterShell(id), ...(latestStatus.get(id) ?? { state: "not_probed", detail: "not_probed" }), ...(resolved.version ? { version: resolved.version, managed: true } : {}), ...(customPath ? { customPath, managed: false, version: undefined } : {}), ...(updateFailures.has(id) ? { updateError: updateFailures.get(id) } : {}) } : adapterFromResolution(id, resolved); }); }, @@ -1136,27 +1139,59 @@ const failureFields = (failure) => ( : { state: failure.state } ); -export function registerAcpIpc(ipcMain, runtime = createAcpHostRuntime(), { allowInstall = () => false, allowPrompt = allowInstall } = {}) { +export function registerAcpIpc(ipcMain, runtime = createAcpHostRuntime(), { + allowInstall = () => false, allowPrompt = allowInstall, + adapterPathStore = { get: async () => null, set: async () => {} }, + chooseAdapterPath = async () => null, + validateAdapterPath = validateTrustedAcpPath, +} = {}) { + let choosing = false; + const withRecordedPath = async (input) => { + const recorded = (input?.id ?? input?.adapterId) === "antigravity" ? await adapterPathStore.get() : null; + return { ...input, path: recorded ? await validateAdapterPath(recorded) : undefined }; + }; const send = (sender, event) => { if (!sender || sender.isDestroyed?.()) return; sender.send("desktop:acp-event", event); }; - ipcMain.handle("desktop:acp-list", () => runtime.listAdapters()); - ipcMain.handle("desktop:acp-probe", (_event, input) => runtime.probeAdapter(input)); - ipcMain.handle("desktop:acp-install", (event, id) => { - if (!allowInstall(event.sender)) throw new Error("adapter_install_forbidden"); - return runtime.installAdapter(id); + ipcMain.handle("desktop:acp-list", async (event) => { + if (!allowPrompt(event.sender, event)) throw new Error("acp_list_forbidden"); + const recorded = await adapterPathStore.get(); + return runtime.listAdapters({ antigravity: recorded }).map((adapter) => adapter.id === "antigravity" && recorded + ? { ...adapter, customPath: recorded } : adapter); }); - ipcMain.handle("desktop:acp-authenticate", (event, input) => { - if (!allowInstall(event.sender)) throw new Error("adapter_auth_forbidden"); - return runtime.authenticateAdapter(input); + ipcMain.handle("desktop:acp-choose-path", async (event, id) => { + if (!allowInstall(event.sender, event) || id !== "antigravity") throw new Error("adapter_path_forbidden"); + if (choosing || event.sender.isFocused?.() !== true) return null; + choosing = true; + try { + const selected = await chooseAdapterPath(); + if (!selected) return null; + await adapterPathStore.set(selected); + runtime.clearAdapterStatus?.("antigravity"); + return selected; + } finally { choosing = false; } }); - ipcMain.handle("desktop:acp-prompt", (event, input) => { - if (!allowPrompt(event.sender)) throw new Error("acp_prompt_forbidden"); - return runtime.prompt(input, (acpEvent) => send(event.sender, acpEvent)); + ipcMain.handle("desktop:acp-probe", async (event, input) => { + if (!allowPrompt(event.sender, event)) throw new Error("acp_probe_forbidden"); + return runtime.probeAdapter(await withRecordedPath(input)); + }); + ipcMain.handle("desktop:acp-install", async (event, id) => { + if (!allowInstall(event.sender, event)) throw new Error("adapter_install_forbidden"); + const result = await runtime.installAdapter(id); + if (id === "antigravity" && result.updated) await adapterPathStore.set(null); + return result; + }); + ipcMain.handle("desktop:acp-authenticate", async (event, input) => { + if (!allowInstall(event.sender, event)) throw new Error("adapter_auth_forbidden"); + return runtime.authenticateAdapter(await withRecordedPath(input)); + }); + ipcMain.handle("desktop:acp-prompt", async (event, input) => { + if (!allowPrompt(event.sender, event)) throw new Error("acp_prompt_forbidden"); + return runtime.prompt(await withRecordedPath(input), (acpEvent) => send(event.sender, acpEvent)); }); ipcMain.handle("desktop:acp-cancel", (event, requestId) => { - if (!allowPrompt(event.sender)) throw new Error("acp_cancel_forbidden"); + if (!allowPrompt(event.sender, event)) throw new Error("acp_cancel_forbidden"); return runtime.cancel(requestId); }); return runtime; diff --git a/apps/desktop/src/main/acp-host.test.mjs b/apps/desktop/src/main/acp-host.test.mjs index 0eea1c6b..6d499159 100644 --- a/apps/desktop/src/main/acp-host.test.mjs +++ b/apps/desktop/src/main/acp-host.test.mjs @@ -1297,6 +1297,6 @@ test("desktop preload and main process expose the ACP host", async () => { expect(preload).toContain('ipcRenderer.on("desktop:acp-event"'); const handlers = new Map(); registerAcpIpc({ handle: (channel, handler) => handlers.set(channel, handler) }); - expect([...handlers.keys()]).toEqual(["desktop:acp-list", "desktop:acp-probe", "desktop:acp-install", "desktop:acp-authenticate", "desktop:acp-prompt", "desktop:acp-cancel"]); - expect(() => handlers.get("desktop:acp-prompt")({ sender: {} }, { prompt: "test" })).toThrow("acp_prompt_forbidden"); + expect([...handlers.keys()]).toEqual(["desktop:acp-list", "desktop:acp-choose-path", "desktop:acp-probe", "desktop:acp-install", "desktop:acp-authenticate", "desktop:acp-prompt", "desktop:acp-cancel"]); + await expect(handlers.get("desktop:acp-prompt")({ sender: {} }, { prompt: "test" })).rejects.toThrow("acp_prompt_forbidden"); }); diff --git a/apps/desktop/src/main/acp-path-store.mjs b/apps/desktop/src/main/acp-path-store.mjs new file mode 100644 index 00000000..c862e52c --- /dev/null +++ b/apps/desktop/src/main/acp-path-store.mjs @@ -0,0 +1,43 @@ +import { mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; + +export const validateTrustedAcpPath = async (filename, { excludedRoot } = {}) => { + const selected = await realpath(filename); + if (excludedRoot) { + const root = await realpath(excludedRoot); + const child = path.relative(root, selected); + if (!child || (child !== ".." && !child.startsWith(`..${path.sep}`) && !path.isAbsolute(child))) { + throw new Error("Choose an ACP executable outside EdgeEver's client data directory"); + } + } + const info = await stat(selected); + if (!info.isFile()) throw new Error("The ACP executable must be a file"); + if (process.platform !== "win32") { + if (info.mode & 0o022) throw new Error("The ACP executable must not be writable by other users"); + for (let directory = path.dirname(selected);; directory = path.dirname(directory)) { + if ((await stat(directory)).mode & 0o022) throw new Error("The ACP executable directory must not be writable by other users"); + if (path.dirname(directory) === directory) break; + } + } + return selected; +}; + +// This store must remain outside all renderer-writable resource directories. +export const createAcpPathStore = (filename) => { + let recorded = null; + const ready = readFile(filename, "utf8").then((text) => { + const value = JSON.parse(text)?.antigravity; + if (typeof value === "string" && path.isAbsolute(value) && !value.includes("\0")) recorded = value; + }).catch(() => {}); + return { + async get() { await ready; return recorded; }, + async set(value) { + await ready; + await mkdir(path.dirname(filename), { recursive: true, mode: 0o700 }); + const temporary = `${filename}.${process.pid}.tmp`; + await writeFile(temporary, JSON.stringify({ antigravity: value }), { mode: 0o600 }); + await rename(temporary, filename); + recorded = value; + }, + }; +}; diff --git a/apps/desktop/src/main/ai-direct.mjs b/apps/desktop/src/main/ai-direct.mjs index 95d10ade..c4c0a553 100644 --- a/apps/desktop/src/main/ai-direct.mjs +++ b/apps/desktop/src/main/ai-direct.mjs @@ -1,26 +1,20 @@ +import { nodePublicFetch } from "@edgeever/public-network/node"; +import { validatePublicUrl } from "@edgeever/public-network/policy"; + const MAX_BODY_BYTES = 12 * 1024 * 1024; const MAX_AUDIO_REQUEST_BYTES = 25_000_000; const MAX_CONCURRENT = 2; -const blockedHost = (hostname) => { - const host = hostname.replace(/^\[|\]$/g, "").toLowerCase(); - if (!host || host === "localhost" || host.endsWith(".local") || host.endsWith(".internal")) return true; - if (/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|0\.|169\.254\.)/.test(host)) return true; - if (host === "::1" || host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) return true; - return false; -}; - export const validateAiProviderUrl = (value) => { - const url = new URL(value); + const url = validatePublicUrl(value); if (url.protocol !== "https:" || url.username || url.password || url.hash) { throw new Error("AI provider requests must use HTTPS without credentials."); } if (url.port && url.port !== "443") throw new Error("AI provider requests must use port 443."); - if (blockedHost(url.hostname)) throw new Error("AI provider host is not allowed."); return url; }; -export const createAiDirectRuntime = ({ fetchImpl = fetch, maxConcurrent = MAX_CONCURRENT } = {}) => { +export const createAiDirectRuntime = ({ fetchImpl = nodePublicFetch, maxConcurrent = MAX_CONCURRENT } = {}) => { const active = new Map(); return { async open(requestId, input, { onData, onEnd, onError }) { @@ -47,6 +41,9 @@ export const createAiDirectRuntime = ({ fetchImpl = fetch, maxConcurrent = MAX_C if (typeof key !== "string" || typeof value !== "string" || /[\r\n\0]/.test(key) || /[\r\n\0]/.test(value)) { throw new Error("Invalid AI provider header."); } + if (["host", "connection", "transfer-encoding", "content-length", "upgrade", "proxy-authorization"].includes(key.toLowerCase())) { + throw new Error("Invalid AI provider header."); + } headers[key] = value; } } diff --git a/apps/desktop/src/main/ai-direct.test.mjs b/apps/desktop/src/main/ai-direct.test.mjs index 842c9f6b..544ecef2 100644 --- a/apps/desktop/src/main/ai-direct.test.mjs +++ b/apps/desktop/src/main/ai-direct.test.mjs @@ -56,7 +56,7 @@ test("desktop AI transport sends multipart audio bytes directly to the speech pr const form = new FormData(); form.set("model", "whisper-1"); form.set("file", new File(["audio bytes"], "clip.mp3", { type: "audio/mpeg" })); - const request = new Request("https://speech.example/v1/audio/transcriptions", { method: "POST", body: form }); + const request = new Request("https://api.openai.com/v1/audio/transcriptions", { method: "POST", body: form }); const headers = Object.fromEntries(request.headers); const bodyBytes = new Uint8Array(await request.arrayBuffer()); const result = await new Promise((resolve, reject) => { @@ -73,12 +73,12 @@ test("desktop AI transport sends multipart audio bytes directly to the speech pr onError: reject, }).catch(reject); }); - expect(received.url).toBe("https://speech.example/v1/audio/transcriptions"); + expect(received.url).toBe("https://api.openai.com/v1/audio/transcriptions"); expect(received.body).toBeInstanceOf(Uint8Array); expect(received.headers["content-type"]).toMatch(/^multipart\/form-data; boundary=/); expect(JSON.parse(result).text).toBe("recognized"); await expect(runtime.open("speech-invalid", { - url: "https://speech.example/v1/chat/completions", + url: "https://api.openai.com/v1/chat/completions", method: "POST", headers, body: "", diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index f4d5eab4..b037452c 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -7,8 +7,9 @@ import { execFile, spawn } from "node:child_process"; import { fileURLToPath, pathToFileURL } from "node:url"; import { release as operatingSystemRelease } from "node:os"; import { SidecarRpcClient } from "./rpc.mjs"; +import { instanceOrigin, encodeSessionCredential, decodeSessionCredential, credentialForUrl } from "./session-credential.mjs"; import { resourceRequestHeaders } from "./resource-request.mjs"; -import { downloadContentDispositionFromRequest, isSafeResourceId, parseByteRangeHeader, resourceIdFromRequest } from "./resource-url.mjs"; +import { downloadContentDispositionFromRequest, isSafeResourceId, parseByteRangeHeader, resourceIdFromRequest, resourceResponseHeaders } from "./resource-url.mjs"; import { isSupportedAssociatedFile } from "./file-association.mjs"; import { createWeChatShareController } from "./wechat-share-import.mjs"; import { enableMacShareExtension } from "./share-extension-registration.mjs"; @@ -29,7 +30,7 @@ import { mountedInstallerCandidates, } from "./installation-location.mjs"; import { userDataDirectoryFromArguments } from "./user-data-directory.mjs"; -import { isAllowedPrintPreviewUrl } from "./window-open-policy.mjs"; +import { isAllowedAppNavigation, isAllowedPrintPreviewUrl } from "./window-open-policy.mjs"; import { showWindow } from "./window-visibility.mjs"; import { globalShortcutAccelerator, normalizeGlobalShortcutBinding, replaceGlobalShortcut, toggleMainWindow } from "./global-shortcut.mjs"; import { trayIconPath } from "./tray-icon.mjs"; @@ -54,6 +55,7 @@ import { inspectWindowsTaskbarShortcuts } from "./windows-taskbar-diagnostics.mj import { testPicGoConnection, createPicGoUploadQueue } from "./picgo.mjs"; import { createPluginPublicNetworkRuntime } from "./plugin-public-network.mjs"; import { createAiDirectRuntime } from "./ai-direct.mjs"; +import { createAcpPathStore, validateTrustedAcpPath } from "./acp-path-store.mjs"; import { createAcpHostRuntime, registerAcpIpc } from "./acp-host.mjs"; import { shouldQuitAfterAllWindowsClosed } from "./window-lifecycle.mjs"; import { @@ -183,6 +185,8 @@ const crashMarkerPath = () => join(app.getPath("userData"), "last-session-active const installationMarkerPath = () => join(app.getPath("userData"), "installation-confirmed"); const logPath = () => join(app.getPath("userData"), "logs", "desktop.log"); let desktopSessionToken = ""; +let desktopSessionTokenOrigin = null; +let instanceChangePending = false; let configuredGlobalShortcut = null; let registeredGlobalShortcut = null; let globalShortcutUpdate = Promise.resolve(); @@ -558,24 +562,40 @@ const loadConfiguredApiBaseUrl = async () => { const loadDesktopSessionToken = async () => { try { const encrypted = await readFile(sessionTokenPath()); - desktopSessionToken = safeStorage.decryptString(encrypted); + const plaintext = safeStorage.decryptString(encrypted); + const credential = decodeSessionCredential(plaintext, configuredApiBaseUrl); + desktopSessionToken = credentialForUrl(credential.token, credential.origin, configuredApiBaseUrl); + desktopSessionTokenOrigin = credential.origin; + if (desktopSessionToken && !plaintext.startsWith("{")) { + await saveDesktopSessionToken(desktopSessionToken); + } } catch { // Existing installations have no main-process credential until the // renderer migrates the legacy localStorage token after upgrading. desktopSessionToken = ""; + desktopSessionTokenOrigin = null; } }; -const saveDesktopSessionToken = async (value) => { +let sessionCredentialWrite = Promise.resolve(); +const saveDesktopSessionToken = (value) => { + const operation = sessionCredentialWrite.then(() => persistDesktopSessionToken(value)); + sessionCredentialWrite = operation.catch(() => {}); + return operation; +}; + +const persistDesktopSessionToken = async (value) => { const normalized = typeof value === "string" ? value.trim() : ""; if (normalized.length > 4096) throw new Error("Desktop session token is too long"); - desktopSessionToken = normalized; - const encrypted = safeStorage.encryptString(normalized); + const origin = instanceOrigin(configuredApiBaseUrl); + const encrypted = safeStorage.encryptString(encodeSessionCredential(normalized, configuredApiBaseUrl)); const temporaryPath = `${sessionTokenPath()}.tmp`; await writeFile(temporaryPath, encrypted, { mode: 0o600 }); await restrictFile(temporaryPath); await rename(temporaryPath, sessionTokenPath()); await restrictFile(sessionTokenPath()); + desktopSessionToken = normalized; + desktopSessionTokenOrigin = origin; }; const pendingDesktopCommands = []; @@ -903,12 +923,8 @@ const handleResourceProtocolRequest = async (request) => { const fileResponse = async (path, contentType, rangeHeader) => { const { size } = await stat(path); const range = parseByteRangeHeader(rangeHeader, size); - const headers = new Headers({ - "Accept-Ranges": "bytes", - "Cache-Control": "no-store", - "Content-Type": contentType || "application/octet-stream", - }); - if (downloadDisposition) headers.set("Content-Disposition", downloadDisposition); + const headers = resourceResponseHeaders(contentType, downloadDisposition); + headers.set("Accept-Ranges", "bytes"); if (range.kind === "invalid") { headers.set("Content-Range", `bytes */${size}`); return new Response(null, { status: 416, headers }); @@ -939,18 +955,16 @@ const handleResourceProtocolRequest = async (request) => { if (!configuredApiBaseUrl) return new Response("Resource is not cached", { status: 504 }); const sourceUrl = `${configuredApiBaseUrl}/api/v1/resources/${encodeURIComponent(resourceId)}/blob`; try { + const sessionToken = credentialForUrl(desktopSessionToken, desktopSessionTokenOrigin, sourceUrl); const cookies = await session.defaultSession.cookies.get({ url: sourceUrl }); - const headers = resourceRequestHeaders({ cookies, sessionToken: desktopSessionToken }); + const headers = resourceRequestHeaders({ cookies, sessionToken }); const rangeHeader = request.headers.get("range"); if (rangeHeader) headers.set("range", rangeHeader); - const response = await net.fetch(sourceUrl, { headers }); + const response = await net.fetch(sourceUrl, { headers, redirect: "error" }); if (!response.ok) return new Response("Resource request failed", { status: response.status }); if (response.status === 206) { - const responseHeaders = new Headers({ - "Accept-Ranges": response.headers.get("accept-ranges") || "bytes", - "Cache-Control": "no-store", - "Content-Type": response.headers.get("content-type") || "application/octet-stream", - }); + const responseHeaders = resourceResponseHeaders(response.headers.get("content-type"), downloadDisposition); + responseHeaders.set("Accept-Ranges", response.headers.get("accept-ranges") || "bytes"); for (const name of ["content-length", "content-range", "etag", "last-modified"]) { const value = response.headers.get(name); if (value) responseHeaders.set(name, value); @@ -1027,13 +1041,8 @@ const registerResourceProtocol = () => { } if (size === null) throw new Error("Staged bytes are missing"); const stream = createReadStream(path); - const headers = new Headers({ - "Content-Type": metadata.type || "application/octet-stream", - "Content-Length": String(size), - "Cache-Control": "no-store", - }); - const downloadDisposition = downloadContentDispositionFromRequest(request.url); - if (downloadDisposition) headers.set("Content-Disposition", downloadDisposition); + const headers = resourceResponseHeaders(metadata.type, downloadContentDispositionFromRequest(request.url)); + headers.set("Content-Length", String(size)); return new Response(Readable.toWeb(stream), { headers, }); @@ -1527,6 +1536,34 @@ const createWindow = async () => { void writeDiagnostic("renderer.responsive"); }); + const rendererEntryUrl = app.isPackaged && !process.env.EDGE_EVER_DESKTOP_WEB_URL + ? (usePrivateAppProtocol ? DESKTOP_APP_ENTRY_URL : pathToFileURL(join(process.resourcesPath, "web/index.html")).href) + : webUrl; + const guardNavigation = (event, url) => { + if (isAllowedAppNavigation(url, rendererEntryUrl)) return; + event.preventDefault(); + }; + mainWindow.webContents.on("will-navigate", guardNavigation); + mainWindow.webContents.on("will-redirect", guardNavigation); + + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (isAllowedPrintPreviewUrl(url, mainWindow.webContents.getURL())) { + return { + action: "allow", + overrideBrowserWindowOptions: { + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + preload: fileURLToPath(new URL("../preload/print.cjs", import.meta.url)), + }, + }, + }; + } + if (url.startsWith("https://") || url.startsWith("http://")) void shell.openExternal(url); + return { action: "deny" }; + }); + try { if (app.isPackaged && !process.env.EDGE_EVER_DESKTOP_WEB_URL) { if (usePrivateAppProtocol) await mainWindow.loadURL(DESKTOP_APP_ENTRY_URL); @@ -1540,28 +1577,6 @@ const createWindow = async () => { }); throw error; } - mainWindow.webContents.setWindowOpenHandler(({ url }) => { - if (url.startsWith("edgeever-resource://") || url.startsWith("edgeever-staged://")) return { action: "allow" }; - if (isAllowedPrintPreviewUrl(url, mainWindow.webContents.getURL())) { - return { - action: "allow", - overrideBrowserWindowOptions: { - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }, - }; - } - if (url.startsWith("https://") || url.startsWith("http://")) void shell.openExternal(url); - return { action: "deny" }; - }); - mainWindow.webContents.on("will-navigate", (event, url) => { - if (url.startsWith(webUrl) || url.startsWith(`${DESKTOP_APP_ORIGIN}/`) || url.startsWith("edgeever-resource://") || url.startsWith("edgeever-staged://")) return; - event.preventDefault(); - if (url.startsWith("https://") || url.startsWith("http://")) void shell.openExternal(url); - }); buildApplicationMenu(); }; @@ -1709,16 +1724,21 @@ const startApplication = async () => { if (rendererStartupGuard?.complete()) void writeDiagnostic("renderer.bootstrap-ready"); }); ipcMain.on("desktop:api-base-url-sync", (event) => { event.returnValue = configuredApiBaseUrl; }); - ipcMain.on("desktop:session-token-sync", (event) => { event.returnValue = desktopSessionToken; }); + ipcMain.on("desktop:session-token-sync", (event) => { + event.returnValue = event.sender === mainWindow?.webContents && event.senderFrame === event.sender.mainFrame + ? credentialForUrl(desktopSessionToken, desktopSessionTokenOrigin, configuredApiBaseUrl) : ""; + }); ipcMain.on("desktop:recovered-after-abnormal-exit-sync", (event) => { event.returnValue = recoveredAfterAbnormalExit; }); ipcMain.handle("desktop:copy-text", (_event, value) => writeTextClipboard(clipboard, value)); ipcMain.handle("desktop:copy-html", (_event, input) => writeRichClipboard(clipboard, ClipboardItem, input)); ipcMain.handle("desktop:copy-image", (_event, bytes) => writeImageClipboard(clipboard, ClipboardItem, bytes)); - ipcMain.handle("desktop:set-session-token", async (_event, value) => { + ipcMain.handle("desktop:set-session-token", async (event, value) => { + if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame || instanceChangePending) throw new Error("Session update forbidden"); await saveDesktopSessionToken(value); return { stored: Boolean(desktopSessionToken) }; }); - ipcMain.handle("desktop:clear-session-token", async () => { + ipcMain.handle("desktop:clear-session-token", async (event) => { + if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame || instanceChangePending) throw new Error("Session update forbidden"); scheduledTaskScheduler.clear(); await saveDesktopSessionToken(""); return { stored: false }; @@ -1783,7 +1803,7 @@ const startApplication = async () => { : join(projectRoot, "scripts", "edgeever-mcp-stdio.mjs"), mcpAccess: () => { const baseUrl = configuredApiBaseUrl; - const sessionToken = desktopSessionToken; + const sessionToken = credentialForUrl(desktopSessionToken, desktopSessionTokenOrigin, baseUrl); const accountId = activeAccountId; return { baseUrl, @@ -1795,7 +1815,16 @@ const startApplication = async () => { isCurrent: () => configuredApiBaseUrl === baseUrl && desktopSessionToken === sessionToken && activeAccountId === accountId, }; }, - }), { allowInstall: (sender) => sender === mainWindow?.webContents }); + }), { + allowInstall: (sender, event) => sender === mainWindow?.webContents && event?.senderFrame === sender.mainFrame, + adapterPathStore: createAcpPathStore(join(app.getPath("userData"), "acp-adapters", "chosen-path.json")), + validateAdapterPath: (filename) => validateTrustedAcpPath(filename, { excludedRoot: app.getPath("userData") }), + chooseAdapterPath: async () => { + const selection = await dialog.showOpenDialog(mainWindow, { properties: ["openFile"], title: app.getLocale().startsWith("zh") ? "选择受信任的 Antigravity ACP 程序" : "Select a trusted Antigravity ACP executable" }); + if (selection.canceled || !selection.filePaths[0]) return null; + return validateTrustedAcpPath(selection.filePaths[0], { excludedRoot: app.getPath("userData") }); + }, + }); await acpRuntime.pruneAdapters().catch(() => {}); const refreshAdapters = () => { void acpRuntime.installDetected().catch(() => []).then(() => acpRuntime.updateInstalled()).catch(() => {}); }; const firstAdapterRefresh = setTimeout(refreshAdapters, 10_000); @@ -1882,24 +1911,43 @@ const startApplication = async () => { setTimeout(() => app.exit(0), 50).unref(); return { scheduled: true }; }); - ipcMain.handle("desktop:set-api-base-url", async (_event, value) => { + ipcMain.handle("desktop:set-api-base-url", async (event, value) => { + if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame) throw new Error("Instance changes must come from the main window"); + if (instanceChangePending) throw new Error("An instance change is already in progress"); const normalized = typeof value === "string" ? value.trim().replace(/\/$/, "") : ""; if (normalized) { let parsed; try { parsed = new URL(normalized); } catch { throw new Error("Desktop API URL must be a valid HTTP(S) URL"); } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("Desktop API URL must use HTTP(S)"); + if (!instanceOrigin(parsed.href) || parsed.hash || parsed.search) throw new Error("Desktop API URL must use HTTP(S) without credentials, query, or fragment"); } if (normalized === configuredApiBaseUrl) return configuredApiBaseUrl; - scheduledTaskScheduler.clear(); - configuredApiBaseUrl = normalized; - await writeFile(instanceUrlPath(), configuredApiBaseUrl); - if (sidecar) { - await stopSidecar(); - const nextSidecar = await startSidecar(activeAccountId); - if (!nextSidecar) throw new Error("EdgeEver sidecar is unavailable after changing instance"); - await nextSidecar.waitUntilReady(); - } - return configuredApiBaseUrl; + instanceChangePending = true; + try { + if (configuredApiBaseUrl && instanceOrigin(normalized) !== instanceOrigin(configuredApiBaseUrl)) { + const chinese = app.getLocale().startsWith("zh"); + const answer = await dialog.showMessageBox(mainWindow, { + type: "warning", + title: chinese ? "切换 EdgeEver 实例" : "Switch EdgeEver instance", + message: chinese ? "切换实例后需要重新登录。" : "You will need to sign in again after switching instances.", + detail: `${configuredApiBaseUrl} → ${normalized}`, + buttons: chinese ? ["取消", "切换实例"] : ["Cancel", "Switch instance"], defaultId: 0, cancelId: 0, + }); + if (answer.response !== 1) throw new Error("Instance change cancelled"); + await saveDesktopSessionToken(""); + } + scheduledTaskScheduler.clear(); + const temporaryInstancePath = `${instanceUrlPath()}.tmp`; + await writeFile(temporaryInstancePath, normalized, { mode: 0o600 }); + await rename(temporaryInstancePath, instanceUrlPath()); + configuredApiBaseUrl = normalized; + if (sidecar) { + await stopSidecar(); + const nextSidecar = await startSidecar(activeAccountId); + if (!nextSidecar) throw new Error("EdgeEver sidecar is unavailable after changing instance"); + await nextSidecar.waitUntilReady(); + } + return configuredApiBaseUrl; + } finally { instanceChangePending = false; } }); ipcMain.handle("desktop:update-status", () => desktopUpdateStatus()); ipcMain.handle("desktop:check-update", async () => { diff --git a/apps/desktop/src/main/resource-url.mjs b/apps/desktop/src/main/resource-url.mjs index e7f75ce4..54d9342e 100644 --- a/apps/desktop/src/main/resource-url.mjs +++ b/apps/desktop/src/main/resource-url.mjs @@ -1,6 +1,25 @@ export const isSafeResourceId = (value) => typeof value === "string" && value.length > 0 && value.length <= 160 && /^[A-Za-z0-9._~-]+$/.test(value) && value !== "." && value !== ".."; +const INLINE_RESOURCE_TYPES = new Set([ + "image/png", "image/jpeg", "image/gif", "image/webp", "image/avif", "image/bmp", + "image/x-icon", "application/pdf", "audio/mpeg", "audio/mp4", "audio/ogg", + "audio/wav", "audio/webm", "video/mp4", "video/webm", "video/ogg", +]); + +export const resourceResponseHeaders = (contentType, disposition = null) => { + const type = typeof contentType === "string" ? contentType.split(";")[0].trim().toLowerCase() : ""; + const inline = INLINE_RESOURCE_TYPES.has(type); + const headers = new Headers({ + "Cache-Control": "no-store", + "Content-Type": inline ? type : "application/octet-stream", + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'none'; sandbox", + }); + if (disposition || !inline) headers.set("Content-Disposition", disposition || "attachment"); + return headers; +}; + export const resourceIdFromRequest = (requestUrl) => { try { const url = new URL(requestUrl); @@ -70,11 +89,8 @@ export const parseByteRangeHeader = (header, totalSize) => { export const cachedResourceResponse = (bytes, contentType, rangeHeader) => { const range = parseByteRangeHeader(rangeHeader, bytes.byteLength); - const headers = new Headers({ - "Accept-Ranges": "bytes", - "Cache-Control": "no-store", - "Content-Type": contentType || "application/octet-stream", - }); + const headers = resourceResponseHeaders(contentType); + headers.set("Accept-Ranges", "bytes"); if (range.kind === "invalid") { headers.set("Content-Range", `bytes */${bytes.byteLength}`); diff --git a/apps/desktop/src/main/security-boundaries.test.mjs b/apps/desktop/src/main/security-boundaries.test.mjs new file mode 100644 index 00000000..74941f13 --- /dev/null +++ b/apps/desktop/src/main/security-boundaries.test.mjs @@ -0,0 +1,145 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { registerAcpIpc } from "./acp-host.mjs"; +import { createAcpPathStore, validateTrustedAcpPath } from "./acp-path-store.mjs"; +import { resourceResponseHeaders } from "./resource-url.mjs"; +import { isAllowedAppNavigation } from "./window-open-policy.mjs"; +import { credentialForUrl, decodeSessionCredential, encodeSessionCredential } from "./session-credential.mjs"; +import { validateAiProviderUrl } from "./ai-direct.mjs"; +import { createPublicLookup } from "@edgeever/public-network/node"; + +test("ACP ignores caller paths on all execution channels and preserves main-owned choices across registration", async () => { + const directory = await mkdtemp(join(tmpdir(), "edgeever-acp-boundary-")); + try { + const filename = join(directory, "chosen.json"); + const sender = { isFocused: () => true }; + const calls = []; + const runtime = { + listAdapters: () => [{ id: "antigravity" }], + probeAdapter: (input) => calls.push(input), + authenticateAdapter: (input) => calls.push(input), + prompt: (input) => calls.push(input), + }; + const register = () => { + const handlers = new Map(); + registerAcpIpc({ handle: (name, handler) => handlers.set(name, handler) }, runtime, { + allowInstall: (value) => value === sender, + adapterPathStore: createAcpPathStore(filename), + chooseAdapterPath: async () => "/user/chosen/acp", + validateAdapterPath: async value => value, + }); + return handlers; + }; + const channels = ["desktop:acp-probe", "desktop:acp-authenticate", "desktop:acp-prompt"]; + const handlers = register(); + for (const channel of channels) { + const input = { id: "antigravity", adapterId: "antigravity", path: "/attacker/payload" }; + await expect(handlers.get(channel)({ sender: {} }, input)).rejects.toThrow(/forbidden/); + await handlers.get(channel)({ sender }, input); + expect(calls.at(-1).path).toBeUndefined(); + } + sender.isFocused = () => false; + expect(await handlers.get("desktop:acp-choose-path")({ sender }, "antigravity")).toBeNull(); + sender.isFocused = () => true; + await handlers.get("desktop:acp-choose-path")({ sender }, "antigravity"); + expect(JSON.parse(await readFile(filename, "utf8")).antigravity).toBe("/user/chosen/acp"); + if (process.platform !== "win32") expect((await stat(filename)).mode & 0o777).toBe(0o600); + const restarted = register(); + for (const channel of channels) { + await restarted.get(channel)({ sender }, { id: "antigravity", adapterId: "antigravity", path: "/attacker/payload" }); + expect(calls.at(-1).path).toBe("/user/chosen/acp"); + } + await restarted.get(channels[0])({ sender }, { id: "codex", path: "/attacker/payload" }); + expect(calls.at(-1).path).toBeUndefined(); + } finally { await rm(directory, { recursive: true, force: true }); } +}); + +test("privileged window cannot navigate to resource documents or origin-prefix spoofs", () => { + for (const app of ["http://127.0.0.1:5173/", "edgeever-app://app/index.html", "file:///app/web/index.html"]) { + expect(isAllowedAppNavigation(app, app)).toBe(true); + for (const target of ["http://127.0.0.1:5173@evil.example/", "http://127.0.0.1:51730/", "edgeever-app://app.evil/index.html", "edgeever-resource://resource/html", "edgeever-staged://html", "file:///etc/passwd"]) { + expect(isAllowedAppNavigation(target, app)).toBe(false); + } + } +}); + +test("ACP chooser permits one focused request and cancelled selection records nothing", async () => { + const handlers = new Map(); + const sender = { isFocused: () => true }; + let finish; + let choices = 0; + let writes = 0; + registerAcpIpc({ handle: (name, handler) => handlers.set(name, handler) }, {}, { + allowInstall: (value) => value === sender, + chooseAdapterPath: () => { choices++; return new Promise((resolve) => { finish = resolve; }); }, + adapterPathStore: { get: async () => null, set: async () => { writes++; } }, + }); + const choose = handlers.get("desktop:acp-choose-path"); + const pending = choose({ sender }, "antigravity"); + expect(await choose({ sender }, "antigravity")).toBeNull(); + expect(choices).toBe(1); + finish(null); + expect(await pending).toBeNull(); + expect(writes).toBe(0); +}); + +test("stored ACP programs are revalidated before the runtime can execute them", async () => { + const handlers = new Map(); + let executed = false; + registerAcpIpc({ handle: (name, handler) => handlers.set(name, handler) }, { + probeAdapter: () => { executed = true; }, + }, { + allowInstall: () => true, + adapterPathStore: { get: async () => "/missing/previous-choice", set: async () => {} }, + }); + await expect(handlers.get("desktop:acp-probe")({ sender: {} }, { id: "antigravity", path: "/attacker" })).rejects.toThrow(); + expect(executed).toBe(false); +}); + +test("resource responses never inline active documents, including MIME variants", () => { + for (const type of ["text/html", "Text/HTML; charset=utf-8", "image/svg+xml", "application/xhtml+xml", "text/plain", "application/octet-stream"]) { + const headers = resourceResponseHeaders(type); + expect(headers.get("content-type")).toBe("application/octet-stream"); + expect(headers.get("content-disposition")).toBe("attachment"); + expect(headers.get("x-content-type-options")).toBe("nosniff"); + expect(headers.get("content-security-policy")).toContain("sandbox"); + } + for (const type of ["image/png", "application/pdf", "video/mp4", "audio/mpeg"]) { + expect(resourceResponseHeaders(type).get("content-type")).toBe(type); + expect(resourceResponseHeaders(type).get("content-disposition")).toBeNull(); + expect(resourceResponseHeaders(type, 'attachment; filename="test"').get("content-disposition")).toContain("test"); + } +}); + +test("legacy credentials migrate to an origin-bound record and cannot follow an instance switch", () => { + const previous = decodeSessionCredential("legacy-test-token", "https://notes.example.org"); + expect(credentialForUrl(previous.token, previous.origin, "https://notes.example.org/api/v1")).toBe("legacy-test-token"); + const restored = decodeSessionCredential(encodeSessionCredential(previous.token, previous.origin), "https://attacker.example.org"); + expect(credentialForUrl(restored.token, restored.origin, "https://attacker.example.org/blob")).toBe(""); + expect(credentialForUrl(restored.token, restored.origin, "http://notes.example.org/blob")).toBe(""); + expect(credentialForUrl(restored.token, restored.origin, "https://notes.example.org:8443/blob")).toBe(""); + expect(decodeSessionCredential('{"version":99}', previous.origin).token).toBe(""); +}); + +test("AI transport rejects mapped IPv6, trailing-dot names and non-public hostnames", async () => { + for (const host of ["[::ffff:127.0.0.1]", "[::ffff:169.254.169.254]", "[::ffff:10.0.0.5]", "localhost.", "service.internal", "service.local", "localhost", "127.0.0.1"]) { + expect(() => validateAiProviderUrl(`https://${host}/v1`)).toThrow(); + } + const lookup = createPublicLookup((_host, _options, callback) => callback(null, [{ address: "127.0.0.1", family: 4 }])); + await expect(new Promise((resolve, reject) => lookup("provider.example.org", {}, (error, address) => error ? reject(error) : resolve(address)))) + .rejects.toThrow(/public addresses/); + const mixed = createPublicLookup((_host, _options, callback) => callback(null, [{ address: "8.8.8.8", family: 4 }, { address: "::ffff:127.0.0.1", family: 6 }])); + await expect(new Promise((resolve, reject) => mixed("provider.example.org", {}, (error, address) => error ? reject(error) : resolve(address)))) + .rejects.toThrow(/public addresses/); +}); + +test("custom ACP executables cannot be anchored in renderer-writable client storage", async () => { + const directory = await mkdtemp(join(tmpdir(), "edgeever-acp-storage-")); + try { + const filename = join(directory, "..payload.bin"); + await writeFile(filename, "test", { mode: 0o700 }); + await expect(validateTrustedAcpPath(filename, { excludedRoot: directory })).rejects.toThrow(/client data directory/); + } finally { await rm(directory, { recursive: true, force: true }); } +}); diff --git a/apps/desktop/src/main/session-credential.mjs b/apps/desktop/src/main/session-credential.mjs new file mode 100644 index 00000000..4832484d --- /dev/null +++ b/apps/desktop/src/main/session-credential.mjs @@ -0,0 +1,22 @@ +export const instanceOrigin = (value) => { + try { + const url = new URL(value); + return ["https:", "http:"].includes(url.protocol) && !url.username && !url.password ? url.origin : null; + } catch { return null; } +}; + +export const encodeSessionCredential = (token, baseUrl) => JSON.stringify({ version: 1, token, origin: instanceOrigin(baseUrl) }); + +export const decodeSessionCredential = (plaintext, baseUrl) => { + // Previous desktop releases encrypted the bare token. Bind that legacy value + // only to the instance loaded at startup, before renderer IPC is registered. + if (!plaintext.startsWith("{")) return { token: plaintext, origin: instanceOrigin(baseUrl) }; + try { + const value = JSON.parse(plaintext); + if (value.version === 1 && typeof value.token === "string" && value.token.length <= 4096 + && typeof value.origin === "string" && instanceOrigin(value.origin) === value.origin) return value; + } catch {} + return { token: "", origin: null }; +}; + +export const credentialForUrl = (token, origin, url) => origin && origin === instanceOrigin(url) ? token : ""; diff --git a/apps/desktop/src/main/wechat-chat-archive.mjs b/apps/desktop/src/main/wechat-chat-archive.mjs index 4249e5ed..0265133e 100644 --- a/apps/desktop/src/main/wechat-chat-archive.mjs +++ b/apps/desktop/src/main/wechat-chat-archive.mjs @@ -143,7 +143,11 @@ const readZipEntries = (bytes) => { const start = entry.localOffset + 30 + readUInt16(data, entry.localOffset + 26) + readUInt16(data, entry.localOffset + 28); const compressed = data.subarray(start, start + entry.compressedSize); if (compressed.length !== entry.compressedSize) throw new WeChatArchiveError("invalid"); - const inflated = entry.method === 0 ? Buffer.from(compressed) : inflateRawSync(compressed); + if (entry.method === 0 && entry.compressedSize !== entry.expandedSize) throw new WeChatArchiveError("invalid"); + let inflated; + try { + inflated = entry.method === 0 ? Buffer.from(compressed) : inflateRawSync(compressed, { maxOutputLength: Math.max(1, entry.expandedSize) }); + } catch { throw new WeChatArchiveError("invalid"); } if (inflated.length !== entry.expandedSize || crc32(inflated) !== entry.crc) throw new WeChatArchiveError("invalid"); return { name: entry.name, data: inflated }; }); diff --git a/apps/desktop/src/main/wechat-chat-archive.test.mjs b/apps/desktop/src/main/wechat-chat-archive.test.mjs index cb4de269..0ecb49e2 100644 --- a/apps/desktop/src/main/wechat-chat-archive.test.mjs +++ b/apps/desktop/src/main/wechat-chat-archive.test.mjs @@ -39,12 +39,12 @@ const zipOf = (entries) => { const crc = crc32(data); const local = Buffer.concat([ u32(0x04034b50), u16(20), u16(0x0800), u16(method), u16(0), u16(0), - u32(crc), u32(compressed.length), u32(data.length), u16(name.length), u16(0), + u32(crc), u32(compressed.length), u32(entry.declaredSize ?? data.length), u16(name.length), u16(0), name, compressed, ]); const central = Buffer.concat([ u32(0x02014b50), u16(20), u16(20), u16(0x0800), u16(method), u16(0), u16(0), - u32(crc), u32(compressed.length), u32(data.length), u16(name.length), u16(0), u16(0), + u32(crc), u32(compressed.length), u32(entry.declaredSize ?? data.length), u16(name.length), u16(0), u16(0), u16(0), u16(0), u32(0), u32(offset), name, ]); locals.push(local); @@ -182,3 +182,10 @@ describe("WeChat chat archive", () => { expect(() => wechatChatNoteFromArchive(zipOf([{ name: "../聊天记录.txt", data: transcript }]))).toThrow(WeChatArchiveError); }); }); + + test("bounds actual deflate output even when the declared size is forged", () => { + const archive = zipOf([{ name: "聊天记录.txt", data: Buffer.alloc(8 * 1024 * 1024, 65), method: 8, declaredSize: 1 }]); + expect(() => wechatChatNoteFromArchive(archive)).toThrow(WeChatArchiveError); + const valid = zipOf([{ name: "聊天记录.txt", data: transcript, method: 8 }]); + expect(wechatChatNoteFromArchive(valid).markdown).toContain("鱼"); +}); diff --git a/apps/desktop/src/main/window-open-policy.mjs b/apps/desktop/src/main/window-open-policy.mjs index 32f9be12..8df4f814 100644 --- a/apps/desktop/src/main/window-open-policy.mjs +++ b/apps/desktop/src/main/window-open-policy.mjs @@ -1,10 +1,22 @@ import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +export const isAllowedAppNavigation = (targetUrl, appUrl) => { + try { + const target = new URL(targetUrl); + const expected = new URL(appUrl); + if (target.username || target.password || target.protocol !== expected.protocol) return false; + if (target.protocol === "file:") return target.host === expected.host && target.pathname === expected.pathname; + return ["http:", "https:", "edgeever-app:"].includes(target.protocol) + && target.hostname === expected.hostname && target.port === expected.port; + } catch { return false; } +}; + export const isAllowedPrintPreviewUrl = (targetUrl, appUrl) => { try { const target = new URL(targetUrl); const currentApp = new URL(appUrl); + if (target.username || target.password) return false; if (currentApp.protocol === "file:") { const expected = pathToFileURL(join(dirname(fileURLToPath(currentApp)), "note-print.html")); diff --git a/apps/desktop/src/preload/index.cjs b/apps/desktop/src/preload/index.cjs index de405251..280816dd 100644 --- a/apps/desktop/src/preload/index.cjs +++ b/apps/desktop/src/preload/index.cjs @@ -54,6 +54,7 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({ return () => ipcRenderer.removeListener("desktop:ai-direct-chunk", listener); }, listAcpAdapters: () => ipcRenderer.invoke("desktop:acp-list"), + chooseAcpAdapterPath: () => ipcRenderer.invoke("desktop:acp-choose-path", "antigravity"), probeAcpAdapter: (input) => ipcRenderer.invoke("desktop:acp-probe", input), installAcpAdapter: (id) => ipcRenderer.invoke("desktop:acp-install", id), authenticateAcpAdapter: (input) => ipcRenderer.invoke("desktop:acp-authenticate", input), diff --git a/apps/desktop/src/preload/print.cjs b/apps/desktop/src/preload/print.cjs new file mode 100644 index 00000000..bdeb5739 --- /dev/null +++ b/apps/desktop/src/preload/print.cjs @@ -0,0 +1 @@ +// Print windows deliberately expose no desktop bridge. diff --git a/apps/web/package.json b/apps/web/package.json index 7d462cfd..d214ea50 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,6 +27,7 @@ "@uiw/react-codemirror": "^4.25.11", "ai": "7.0.137", "beautiful-mermaid": "^1.1.3", + "dompurify": "3.4.16", "docx-preview": "0.4.1", "fabric": "7.4.0", "highlight.js": "^11.11.1", diff --git a/apps/web/src/components/TemplatesPane.tsx b/apps/web/src/components/TemplatesPane.tsx index 88f56b33..dbbe5ed1 100644 --- a/apps/web/src/components/TemplatesPane.tsx +++ b/apps/web/src/components/TemplatesPane.tsx @@ -15,7 +15,7 @@ import { FilePlus } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { marked } from "marked"; +import { safeMarkdownPreview } from "@/lib/safe-markdown-preview"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -148,12 +148,12 @@ export const TemplatesPane = ({ }; const draftHtmlPreview = useMemo( - () => (draft.contentMarkdown ? (marked.parse(draft.contentMarkdown) as string) : ""), + () => (draft.contentMarkdown ? safeMarkdownPreview(draft.contentMarkdown) : ""), [draft.contentMarkdown] ); const previewHtmlContent = useMemo( - () => (previewTemplate?.contentMarkdown ? (marked.parse(previewTemplate.contentMarkdown) as string) : ""), + () => (previewTemplate?.contentMarkdown ? safeMarkdownPreview(previewTemplate.contentMarkdown) : ""), [previewTemplate?.contentMarkdown] ); diff --git a/apps/web/src/components/settings/DesktopAcpAgentCard.tsx b/apps/web/src/components/settings/DesktopAcpAgentCard.tsx index d3d75abc..9f5242f8 100644 --- a/apps/web/src/components/settings/DesktopAcpAgentCard.tsx +++ b/apps/web/src/components/settings/DesktopAcpAgentCard.tsx @@ -129,7 +129,10 @@ const DesktopAcpAgentCardBody = ({ bridge }: { bridge: boolean }) => { useEffect(() => { let cancelled = false; const refresh = () => void listDesktopAcpAdapters().then((adapters) => { - if (!cancelled) setListed(adapters); + if (!cancelled) { + setListed(adapters); + setAdapterPath(adapters.find((adapter) => adapter.id === "antigravity")?.customPath ?? ""); + } }).catch(() => {}); refresh(); const interval = window.setInterval(refresh, 5_000); @@ -311,7 +314,7 @@ const DesktopAcpAgentCardBody = ({ bridge }: { bridge: boolean }) => { {adapterId === "antigravity" ? ( -