diff --git a/apps/desktop/src/main/acp-host.mjs b/apps/desktop/src/main/acp-host.mjs index e2aefbe0..5a96915b 100644 --- a/apps/desktop/src/main/acp-host.mjs +++ b/apps/desktop/src/main/acp-host.mjs @@ -10,6 +10,7 @@ import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, RequestError } fr import { waitForChildProcessSpawn } from "./child-process-start.mjs"; import { createAcpAdapterManager } from "./acp-adapter-manager.mjs"; import { startAcpMcpBridge } from "./acp-mcp-bridge.mjs"; +import { withAntigravityMacProxy } from "./antigravity-proxy.mjs"; const ADAPTERS = { codex: { id: "codex", label: "Codex" }, @@ -687,12 +688,15 @@ export function createAcpHostRuntime(options = {}) { ], }); - const resolveCommand = (input) => { - if (input?.id === "antigravity" && typeof input.path === "string" && input.path.trim()) return resolveAcpCommand(input, commandDeps); + const resolveCommand = (input, { prepareProxy = true } = {}) => { + const adapt = (resolution) => prepareProxy && input?.id === "antigravity" && resolution.ok + ? { ...resolution, command: withAntigravityMacProxy(resolution.command, commandDeps) } + : resolution; + if (input?.id === "antigravity" && typeof input.path === "string" && input.path.trim()) return adapt(resolveAcpCommand(input, commandDeps)); if (input?.id === "piAgent" && !localExecutable("pi", resolutionDeps(commandDeps))) return { ok: false, state: "not_installed" }; const managed = manager?.get(input?.id); - if (managed) return { ok: true, command: input?.id === "piAgent" ? withPiPath(managed.command, resolutionDeps(commandDeps)) : managed.command, version: managed.version, managed: true }; - return resolveAcpCommand(input, commandDeps); + if (managed) return adapt({ ok: true, command: input?.id === "piAgent" ? withPiPath(managed.command, resolutionDeps(commandDeps)) : managed.command, version: managed.version, managed: true }); + return adapt(resolveAcpCommand(input, commandDeps)); }; const connect = async (command, requestId, emit, signal, authMethodId, mcpServers = []) => { @@ -754,7 +758,7 @@ export function createAcpHostRuntime(options = {}) { listAdapters() { 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 }); + const resolved = resolveCommand({ id }, { prepareProxy: false }); return resolved.ok ? { ...adapterShell(id), ...(latestStatus.get(id) ?? { state: "failed", detail: "not_probed" }), ...(resolved.version ? { version: resolved.version, managed: true } : {}), ...(updateFailures.has(id) ? { updateError: updateFailures.get(id) } : {}) } : adapterFromResolution(id, resolved); @@ -769,7 +773,9 @@ export function createAcpHostRuntime(options = {}) { const result = await manager.install(id, async (command) => { let connected; try { - connected = await withHandshakeTimeout((signal) => connect(id === "piAgent" ? withPiPath(command, resolutionDeps(commandDeps)) : command, `install-${id}`, () => {}, signal), 90_000); + const prepared = id === "piAgent" ? withPiPath(command, resolutionDeps(commandDeps)) + : id === "antigravity" ? withAntigravityMacProxy(command, commandDeps) : command; + connected = await withHandshakeTimeout((signal) => connect(prepared, `install-${id}`, () => {}, signal), 90_000); return { ...adapterShell(id), state: "available", promptCapabilities: connected.promptCapabilities }; } catch (error) { return { ...adapterShell(id), ...failureFields(classifyAcpFailure(error)), ...(isAuthRequiredError(error) ? { authMethods: error.authMethods ?? [] } : {}) }; diff --git a/apps/desktop/src/main/acp-host.test.mjs b/apps/desktop/src/main/acp-host.test.mjs index 5cef68c1..e8808781 100644 --- a/apps/desktop/src/main/acp-host.test.mjs +++ b/apps/desktop/src/main/acp-host.test.mjs @@ -580,13 +580,18 @@ describe("ACP stdio session", () => { const { spawn } = await import("node:child_process"); const spawned = []; const runtime = createAcpHostRuntime({ + platform: "darwin", + env: {}, + readProxy: () => "HTTPEnable : 1\nHTTPProxy : 127.0.0.1\nHTTPPort : 10808\nHTTPSEnable : 1\nHTTPSProxy : 127.0.0.1\nHTTPSPort : 10809\nSOCKSEnable : 1", spawnImpl(command, args, options) { - spawned.push({ command, args, cwd: options.cwd, shell: options.shell }); + spawned.push({ command, args, cwd: options.cwd, shell: options.shell, httpProxy: options.env?.HTTP_PROXY, httpsProxy: options.env?.HTTPS_PROXY }); return spawn(command, args, options); }, }); const probed = await runtime.probeAdapter({ id: "antigravity", path: wrapper }); expect(spawned[0]?.shell).toBe(false); + expect(spawned[0]?.httpProxy).toBe("http://127.0.0.1:10808"); + expect(spawned[0]?.httpsProxy).toBe("http://127.0.0.1:10809"); expect(realpathSync(spawned[0]?.command)).toBe(realpathSync(wrapper)); expect(probed.state).toBe("available"); expect(probed.promptCapabilities).toEqual({ image: true, embeddedContext: true }); diff --git a/apps/desktop/src/main/antigravity-proxy.mjs b/apps/desktop/src/main/antigravity-proxy.mjs new file mode 100644 index 00000000..48f2f3c0 --- /dev/null +++ b/apps/desktop/src/main/antigravity-proxy.mjs @@ -0,0 +1,39 @@ +import { execFileSync } from "node:child_process"; + +const PROXY_ENV_KEYS = ["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"]; + +const proxyUrl = (settings, prefix) => { + if (settings[`${prefix}Enable`] !== "1") return null; + const host = settings[`${prefix}Proxy`]; + const port = Number(settings[`${prefix}Port`]); + if (!host || !Number.isInteger(port) || port < 1 || port > 65535) return null; + const bracketed = host.includes(":") ? `[${host}]` : host; + try { + const url = new URL(`http://${bracketed}:${port}`); + return url.username || url.password || url.pathname !== "/" || url.search || url.hash ? null : url.origin; + } catch { + return null; + } +}; + +export function antigravityMacProxyEnv({ platform = process.platform, env = process.env, readProxy = () => execFileSync("/usr/sbin/scutil", ["--proxy"], { encoding: "utf8", timeout: 2_000, maxBuffer: 64 * 1024 }) } = {}) { + if (platform !== "darwin" || PROXY_ENV_KEYS.some((key) => env[key])) return null; + let output; + try { output = readProxy(); } catch { return null; } + if (typeof output !== "string") return null; + const settings = Object.fromEntries([...output.matchAll(/^\s*(\w+)\s*:\s*([^\r\n]+)\s*$/gm)].map((match) => [match[1], match[2].trim()])); + // The Antigravity ACP binary lacks python-socks. An HTTP proxy supplied in + // the environment takes precedence over macOS's system SOCKS proxy. + if (settings.SOCKSEnable !== "1") return null; + const http = proxyUrl(settings, "HTTP"); + const https = proxyUrl(settings, "HTTPS"); + if (!http && !https) return null; + return { ...(http ? { HTTP_PROXY: http } : {}), ...(https ? { HTTPS_PROXY: https } : {}) }; +} + +export function withAntigravityMacProxy(command, options) { + const proxyEnv = antigravityMacProxyEnv(options); + if (!proxyEnv) return command; + const spec = typeof command === "string" ? { command, args: [] } : command; + return { ...spec, env: { ...spec.env, ...proxyEnv } }; +} diff --git a/apps/desktop/src/main/antigravity-proxy.test.mjs b/apps/desktop/src/main/antigravity-proxy.test.mjs new file mode 100644 index 00000000..c459acbc --- /dev/null +++ b/apps/desktop/src/main/antigravity-proxy.test.mjs @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { antigravityMacProxyEnv, withAntigravityMacProxy } from "./antigravity-proxy.mjs"; + +const systemProxy = ` { + HTTPEnable : 1 + HTTPProxy : 127.0.0.1 + HTTPPort : 10808 + HTTPSEnable : 1 + HTTPSProxy : 127.0.0.1 + HTTPSPort : 10809 + SOCKSEnable : 1 + SOCKSProxy : 127.0.0.1 + SOCKSPort : 10808 +}`; + +describe("Antigravity macOS proxy", () => { + test("uses the system HTTP endpoints when SOCKS would break the bundled ACP server", () => { + const options = { platform: "darwin", env: {}, readProxy: () => systemProxy }; + expect(antigravityMacProxyEnv(options)).toEqual({ + HTTP_PROXY: "http://127.0.0.1:10808", + HTTPS_PROXY: "http://127.0.0.1:10809", + }); + expect(withAntigravityMacProxy({ command: "/tmp/agy_acp_server.par", args: [] }, options).env).toEqual(antigravityMacProxyEnv(options)); + }); + + test("preserves explicit proxy choices and other platforms", () => { + const readProxy = () => systemProxy; + expect(antigravityMacProxyEnv({ platform: "darwin", env: { https_proxy: "socks5://localhost:9999" }, readProxy })).toBeNull(); + expect(antigravityMacProxyEnv({ platform: "linux", env: {}, readProxy })).toBeNull(); + expect(antigravityMacProxyEnv({ platform: "darwin", env: {}, readProxy: () => systemProxy.replace("SOCKSEnable : 1", "SOCKSEnable : 0") })).toBeNull(); + }); + + test("does not use a SOCKS-only or malformed HTTP proxy", () => { + expect(antigravityMacProxyEnv({ platform: "darwin", env: {}, readProxy: () => "SOCKSEnable : 1\nSOCKSProxy : localhost\nSOCKSPort : 1080" })).toBeNull(); + expect(antigravityMacProxyEnv({ platform: "darwin", env: {}, readProxy: () => systemProxy.replaceAll("127.0.0.1", "user@untrusted.example") })).toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/DesktopAcpAgentCard.tsx b/apps/web/src/components/settings/DesktopAcpAgentCard.tsx index f3cdbf31..9e8a114f 100644 --- a/apps/web/src/components/settings/DesktopAcpAgentCard.tsx +++ b/apps/web/src/components/settings/DesktopAcpAgentCard.tsx @@ -9,6 +9,7 @@ import { AI_SIDEBAR_ADAPTER_KEY, AI_SIDEBAR_ADAPTER_PATH_KEY, AI_SIDEBAR_SOURCE_KEY, + displayedDesktopAcpAdapter, listDesktopAcpAdapters, installDesktopAcpAdapter, authenticateDesktopAcpAdapter, @@ -116,10 +117,7 @@ const DesktopAcpAgentCardBody = ({ bridge }: { bridge: boolean }) => { }; }, []); - const listedCurrent = listed.find((adapter) => adapter.id === adapterId); - const shown = listedCurrent?.state === "installing" || (listedCurrent?.managed && (!probed?.managed || listedCurrent.version !== probed.version)) - ? listedCurrent - : probed?.id === adapterId ? probed : listedCurrent; + const shown = displayedDesktopAcpAdapter({ id: adapterId, path: adapterPath, listed, probed }); const status = t(statusKey(shown, probing)); const tone = getStatusTone({ probing, diff --git a/apps/web/src/lib/desktop-acp.test.mjs b/apps/web/src/lib/desktop-acp.test.mjs new file mode 100644 index 00000000..fcdbe449 --- /dev/null +++ b/apps/web/src/lib/desktop-acp.test.mjs @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; +import { displayedDesktopAcpAdapter } from "./desktop-acp.ts"; + +test("a checked custom Antigravity executable takes priority over the managed connector status", () => { + const managed = { id: "antigravity", label: "Antigravity", state: "failed", version: "1.2.1", managed: true }; + const custom = { id: "antigravity", label: "Antigravity", state: "available" }; + const input = { id: "antigravity", path: "/tmp/agy-acp", listed: [managed], probed: custom }; + expect(displayedDesktopAcpAdapter(input)).toBe(custom); + expect(displayedDesktopAcpAdapter({ ...input, probed: null })).toBeUndefined(); + expect(displayedDesktopAcpAdapter({ ...input, path: "" })).toBe(managed); +}); + +test("a newly installed managed connector takes priority over an older probe", () => { + const current = { id: "antigravity", label: "Antigravity", state: "available", version: "1.2.2", managed: true }; + const previous = { id: "antigravity", label: "Antigravity", state: "failed", version: "1.2.1", managed: true }; + expect(displayedDesktopAcpAdapter({ id: "antigravity", path: "", listed: [current], probed: previous })).toBe(current); +}); diff --git a/apps/web/src/lib/desktop-acp.ts b/apps/web/src/lib/desktop-acp.ts index bd2dbc11..4d71e42b 100644 --- a/apps/web/src/lib/desktop-acp.ts +++ b/apps/web/src/lib/desktop-acp.ts @@ -18,6 +18,24 @@ export type DesktopAcpAdapter = { authMethods?: Array<{ id: string; name: string }>; }; +export const displayedDesktopAcpAdapter = ({ + id, + path, + listed, + probed, +}: { + id: DesktopAcpAdapterId; + path: string; + listed: DesktopAcpAdapter[]; + probed: DesktopAcpAdapter | null; +}): DesktopAcpAdapter | undefined => { + const current = listed.find((adapter) => adapter.id === id); + const checked = probed?.id === id ? probed : undefined; + if (id === "antigravity" && path.trim()) return checked; + if (current?.state === "installing" || (current?.managed && (!checked?.managed || current.version !== checked.version))) return current; + return checked ?? current; +}; + export type DesktopAcpAttachment = { filename: string; mediaType: string; diff --git a/package.json b/package.json index f8fc751a..d74a2d75 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "release": "bun scripts/release.mjs", "publish:stores": "bun scripts/store-delivery.mjs", "test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs", - "test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/app-protocol.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-menu.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/wechat-chat-archive.test.mjs apps/desktop/src/main/wechat-share-import.test.mjs apps/desktop/src/main/share-extension-registration.test.mjs apps/desktop/src/main/windows-share-menu.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/ai-direct.test.mjs apps/desktop/src/main/plugin-public-network.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-hibernate.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/renderer-storage-migration.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/scheduled-task-scheduler.test.mjs apps/desktop/src/main/screenshot-capture.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/staged-resource-alias.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-workspace-restore.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/screenshot-import.test.mjs apps/web/src/lib/wechat-chat-import.test.mjs apps/web/src/lib/shared-file-import.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun test --cwd apps/desktop src/main/acp-adapter-manager.test.mjs src/main/acp-host.test.mjs src/main/acp-mcp-bridge.test.mjs && bun run test:desktop:sidecar", + "test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/app-protocol.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-menu.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/wechat-chat-archive.test.mjs apps/desktop/src/main/wechat-share-import.test.mjs apps/desktop/src/main/share-extension-registration.test.mjs apps/desktop/src/main/windows-share-menu.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/ai-direct.test.mjs apps/desktop/src/main/plugin-public-network.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-hibernate.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/renderer-storage-migration.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/scheduled-task-scheduler.test.mjs apps/desktop/src/main/screenshot-capture.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/staged-resource-alias.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-workspace-restore.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-acp.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/screenshot-import.test.mjs apps/web/src/lib/wechat-chat-import.test.mjs apps/web/src/lib/shared-file-import.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun test --cwd apps/desktop src/main/acp-adapter-manager.test.mjs src/main/acp-host.test.mjs src/main/antigravity-proxy.test.mjs src/main/acp-mcp-bridge.test.mjs && bun run test:desktop:sidecar", "verify:desktop-renderer": "bun scripts/verify-desktop-renderer.mjs", "verify:renderer-origin-migration": "bun run --cwd apps/desktop verify-origin-migration", "verify:desktop-cross-version-startup": "bun scripts/verify-desktop-cross-version-startup.mjs",