diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 169fa8cc7d4..6a3ab3a8b5b 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -23,6 +23,10 @@ import { } from "main/lib/project-icons"; import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime"; import { PROJECT_COLOR_VALUES } from "shared/constants/project-colors"; +import { + sanitizeTerminalProxyOverrideInput, + terminalProxyOverrideInputSchema, +} from "shared/terminal-proxy-input"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { resolveDefaultEditor } from "../external"; @@ -1408,6 +1412,9 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { worktreeBaseDir: z.string().nullable().optional(), hideImage: z.boolean().optional(), defaultApp: z.enum(EXTERNAL_APPS).nullable().optional(), + terminalProxyOverride: terminalProxyOverrideInputSchema + .nullable() + .optional(), }), }), ) @@ -1421,6 +1428,30 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { throw new Error(`Project ${input.id} not found`); } + let terminalProxyOverridePatch: + | { terminalProxyOverride: null | ReturnType } + | undefined; + if (input.patch.terminalProxyOverride !== undefined) { + if (!input.patch.terminalProxyOverride) { + terminalProxyOverridePatch = { terminalProxyOverride: null }; + } else { + try { + terminalProxyOverridePatch = { + terminalProxyOverride: sanitizeTerminalProxyOverrideInput( + input.patch.terminalProxyOverride, + ), + }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Invalid proxy URL"; + throw new TRPCError({ + code: "BAD_REQUEST", + message, + }); + } + } + } + localDb .update(projects) .set({ @@ -1446,6 +1477,36 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { ...(input.patch.defaultApp !== undefined && { defaultApp: input.patch.defaultApp, }), + ...(terminalProxyOverridePatch ?? {}), + lastOpenedAt: Date.now(), + }) + .where(eq(projects.id, input.id)) + .run(); + + return { success: true }; + }), + + resetTerminalProxyOverride: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(({ input }) => { + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.id)) + .get(); + if (!project) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Project ${input.id} not found`, + }); + } + + localDb + .update(projects) + .set({ + terminalProxyOverride: { + mode: "inherit", + }, lastOpenedAt: Date.now(), }) .where(eq(projects.id, input.id)) diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index 5876d80fd22..da3f4bf4889 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -9,6 +9,7 @@ import { settings, TERMINAL_LINK_BEHAVIORS, type TerminalPreset, + type TerminalProxySettings, } from "@superset/local-db"; import { AGENT_PRESET_COMMANDS, @@ -20,6 +21,10 @@ import { app } from "electron"; import { exitImmediately } from "main/index"; import { hasCustomRingtone } from "main/lib/custom-ringtones"; import { localDb } from "main/lib/local-db"; +import { + getDetectedInheritedProxy as getDetectedInheritedProxyInternal, + getGlobalTerminalProxySettings, +} from "main/lib/terminal/terminal-proxy"; import { DEFAULT_AUTO_APPLY_DEFAULT_PRESET, DEFAULT_CONFIRM_ON_QUIT, @@ -36,6 +41,13 @@ import { DEFAULT_RINGTONE_ID, isBuiltInRingtoneId, } from "shared/ringtones"; +import { + maskProxyUrlCredentials, +} from "shared/terminal-proxy"; +import { + sanitizeTerminalProxySettingsInput, + terminalProxySettingsInputSchema, +} from "shared/terminal-proxy-input"; import { type AgentDefinitionId, applyCustomAgentDefinitionPatch, @@ -168,7 +180,6 @@ function clearCustomAgentPresetOverride(id: `custom:${string}`) { }), ); } - function getResolvedAgentPresets() { return resolveAgentConfigs({ customDefinitions: readRawAgentCustomDefinitions(), @@ -639,6 +650,61 @@ export const createSettingsRouter = () => { return row.terminalLinkBehavior ?? DEFAULT_TERMINAL_LINK_BEHAVIOR; }), + getTerminalProxySettings: publicProcedure.query(() => { + return getGlobalTerminalProxySettings(); + }), + + setTerminalProxySettings: publicProcedure + .input(terminalProxySettingsInputSchema) + .mutation(({ input }) => { + let next: TerminalProxySettings; + try { + next = sanitizeTerminalProxySettingsInput(input); + } catch (error) { + const message = + error instanceof Error ? error.message : "Invalid proxy URL"; + throw new TRPCError({ + code: "BAD_REQUEST", + message, + }); + } + localDb + .insert(settings) + .values({ id: 1, terminalProxySettings: next }) + .onConflictDoUpdate({ + target: settings.id, + set: { terminalProxySettings: next }, + }) + .run(); + + return { success: true }; + }), + + getDetectedInheritedProxy: publicProcedure + .input( + z + .object({ + refresh: z.boolean().optional(), + nonce: z.number().optional(), + }) + .optional(), + ) + .query(async ({ input }) => { + const detected = await getDetectedInheritedProxyInternal({ + forceRefresh: input?.refresh ?? false, + }); + return { + hasProxy: detected.hasProxy, + httpProxy: detected.httpProxy + ? maskProxyUrlCredentials(detected.httpProxy) + : undefined, + httpsProxy: detected.httpsProxy + ? maskProxyUrlCredentials(detected.httpsProxy) + : undefined, + noProxy: detected.noProxy, + }; + }), + setTerminalLinkBehavior: publicProcedure .input(z.object({ behavior: z.enum(TERMINAL_LINK_BEHAVIORS) })) .mutation(({ input }) => { diff --git a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts index d1a7fb8b5eb..b69c83820a8 100644 --- a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts +++ b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; import { EventEmitter } from "node:events"; +import type { EffectiveTerminalProxy } from "shared/terminal-proxy"; import { TERMINAL_ATTACH_CANCELED_MESSAGE, TerminalAttachCanceledError, @@ -7,7 +8,11 @@ import { import type { SessionInfo } from "./types"; class MockTerminalHostClient extends EventEmitter { - createOrAttachCalls: Array<{ sessionId: string; requestId?: string }> = []; + createOrAttachCalls: Array<{ + sessionId: string; + requestId?: string; + env?: Record; + }> = []; cancelCreateOrAttachCalls: Array<{ sessionId: string; requestId: string }> = []; killCalls: Array<{ sessionId: string; deleteHistory?: boolean }> = []; @@ -69,7 +74,11 @@ class MockTerminalHostClient extends EventEmitter { } async createOrAttach( - params: { sessionId: string; requestId?: string }, + params: { + sessionId: string; + requestId?: string; + env?: Record; + }, signal?: AbortSignal, ) { if (this.createOrAttachGate) { @@ -165,6 +174,11 @@ class MockTerminalHostClient extends EventEmitter { } let mockClient = new MockTerminalHostClient(); +let mockEffectiveTerminalProxy: EffectiveTerminalProxy = { + state: "none", + source: "none", +}; +const resolveEffectiveTerminalProxyForWorkspaceCalls: string[] = []; mock.module("../../terminal-host/client", () => ({ getTerminalHostClient: () => mockClient, @@ -176,10 +190,43 @@ mock.module("main/lib/analytics", () => ({ })); mock.module("../env", () => ({ - buildTerminalEnv: () => ({}), + buildTerminalEnv: () => ({ + PATH: "/usr/bin", + HTTP_PROXY: "http://inherited-proxy:8080", + }), getDefaultShell: () => "/bin/zsh", })); +mock.module("../terminal-proxy", () => ({ + resolveEffectiveTerminalProxyForWorkspace: async ({ + workspaceId, + }: { + workspaceId: string; + }) => { + resolveEffectiveTerminalProxyForWorkspaceCalls.push(workspaceId); + return mockEffectiveTerminalProxy; + }, + applyTerminalProxyToEnv: ( + env: Record, + effective: EffectiveTerminalProxy, + ) => { + const next = { ...env }; + delete next.HTTP_PROXY; + delete next.HTTPS_PROXY; + delete next.http_proxy; + delete next.https_proxy; + delete next.NO_PROXY; + delete next.no_proxy; + if (effective.state === "manual" && effective.config) { + next.HTTP_PROXY = effective.config.proxyUrl; + next.HTTPS_PROXY = effective.config.proxyUrl; + next.http_proxy = effective.config.proxyUrl; + next.https_proxy = effective.config.proxyUrl; + } + return next; + }, +})); + mock.module("main/lib/app-state", () => ({ appState: { data: null }, })); @@ -242,6 +289,8 @@ const { DaemonTerminalManager } = await import("./daemon-manager"); describe("DaemonTerminalManager kill tracking", () => { beforeEach(() => { mockClient = new MockTerminalHostClient(); + mockEffectiveTerminalProxy = { state: "none", source: "none" }; + resolveEffectiveTerminalProxyForWorkspaceCalls.length = 0; }); afterAll(() => { @@ -564,4 +613,82 @@ describe("DaemonTerminalManager kill tracking", () => { await expect(manager.forceKillAll()).rejects.toThrow("probe failed"); expect(mockClient.killAllCalls).toBe(0); }); + + it("applies effective terminal proxy env for newly created sessions", async () => { + const manager = new DaemonTerminalManager(); + const paneId = "pane-proxy-1"; + const requestId = "req-proxy-1"; + const managerInternals = manager as unknown as { + daemonSessionIdsHydrated: boolean; + daemonAliveSessionIds: Set; + }; + managerInternals.daemonSessionIdsHydrated = true; + managerInternals.daemonAliveSessionIds = new Set(); + + mockEffectiveTerminalProxy = { + state: "manual", + source: "project", + config: { + proxyUrl: "http://project-proxy:8080", + }, + }; + + const attachPromise = manager.createOrAttach({ + paneId, + requestId, + tabId: "tab-1", + workspaceId: "ws-1", + skipColdRestore: true, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(resolveEffectiveTerminalProxyForWorkspaceCalls).toEqual(["ws-1"]); + expect(mockClient.createOrAttachCalls[0]?.env).toMatchObject({ + PATH: "/usr/bin", + HTTP_PROXY: "http://project-proxy:8080", + HTTPS_PROXY: "http://project-proxy:8080", + http_proxy: "http://project-proxy:8080", + https_proxy: "http://project-proxy:8080", + }); + + mockClient.resolveCreateOrAttach(requestId, 123); + await expect(attachPromise).resolves.toMatchObject({ isNew: true }); + }); + + it("skips effective proxy resolution when daemon session already exists", async () => { + const manager = new DaemonTerminalManager(); + const paneId = "pane-proxy-existing"; + const requestId = "req-proxy-existing"; + const managerInternals = manager as unknown as { + daemonSessionIdsHydrated: boolean; + daemonAliveSessionIds: Set; + }; + managerInternals.daemonSessionIdsHydrated = true; + managerInternals.daemonAliveSessionIds = new Set([paneId]); + mockEffectiveTerminalProxy = { + state: "manual", + source: "project", + config: { + proxyUrl: "http://project-proxy:8080", + }, + }; + + const attachPromise = manager.createOrAttach({ + paneId, + requestId, + tabId: "tab-1", + workspaceId: "ws-1", + skipColdRestore: true, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(resolveEffectiveTerminalProxyForWorkspaceCalls).toEqual([]); + expect(mockClient.createOrAttachCalls[0]?.env).toMatchObject({ + PATH: "/usr/bin", + HTTP_PROXY: "http://inherited-proxy:8080", + }); + + mockClient.resolveCreateOrAttach(requestId, 123); + await expect(attachPromise).resolves.toMatchObject({ isNew: true }); + }); }); diff --git a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts index 10e6bf685a4..a48a3edc9d0 100644 --- a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts @@ -14,6 +14,10 @@ import { raceWithAbort, throwIfAborted } from "../abort"; import { buildTerminalEnv, getDefaultShell } from "../env"; import { TerminalKilledError } from "../errors"; import { portManager } from "../port-manager"; +import { + applyTerminalProxyToEnv, + resolveEffectiveTerminalProxyForWorkspace, +} from "../terminal-proxy"; import type { CreateSessionParams, SessionResult } from "../types"; import { CREATE_OR_ATTACH_CONCURRENCY, @@ -438,6 +442,26 @@ export class DaemonTerminalManager extends EventEmitter { rootPath, themeType, }); + let effectiveEnv = env; + let effectiveProxy: Awaited< + ReturnType + > = { + state: "none", + source: "none", + }; + if (!daemonHasSession) { + try { + effectiveProxy = await resolveEffectiveTerminalProxyForWorkspace({ + workspaceId, + }); + } catch (error) { + console.error( + "[DaemonTerminalManager] Failed to resolve effective terminal proxy:", + error, + ); + } + effectiveEnv = applyTerminalProxyToEnv(env, effectiveProxy); + } if (DEBUG_TERMINAL) { console.log("[DaemonTerminalManager] Calling daemon createOrAttach:", { @@ -446,6 +470,8 @@ export class DaemonTerminalManager extends EventEmitter { cwd, cols, rows, + proxyState: effectiveProxy.state, + proxySource: effectiveProxy.source, }); } @@ -478,7 +504,7 @@ export class DaemonTerminalManager extends EventEmitter { cols, rows, cwd, - env, + env: effectiveEnv, shell, command, }, diff --git a/apps/desktop/src/main/lib/terminal/terminal-proxy.ts b/apps/desktop/src/main/lib/terminal/terminal-proxy.ts new file mode 100644 index 00000000000..b69a81f3d4c --- /dev/null +++ b/apps/desktop/src/main/lib/terminal/terminal-proxy.ts @@ -0,0 +1,147 @@ +import { + projects, + settings, + type TerminalProxyOverride, + type TerminalProxySettings, + workspaces, +} from "@superset/local-db"; +import { eq } from "drizzle-orm"; +import { + getProcessEnvWithShellEnv, + getShellEnvironment, +} from "lib/trpc/routers/workspaces/utils/shell-env"; +import { localDb } from "main/lib/local-db"; +import type { + DetectedInheritedProxy, + EffectiveTerminalProxy, +} from "shared/terminal-proxy"; +import { + buildProxyEnvVars, + detectInheritedProxyFromEnv, + resolveEffectiveTerminalProxyFromSettings, + stripProxyEnvVars, +} from "shared/terminal-proxy"; + +const DEFAULT_GLOBAL_PROXY_SETTINGS: TerminalProxySettings = { + mode: "auto", +}; + +const DEFAULT_PROJECT_PROXY_OVERRIDE: TerminalProxyOverride = { + mode: "inherit", +}; + +function normalizeGlobalSettings( + value: TerminalProxySettings | null | undefined, +): TerminalProxySettings { + if (!value) { + return DEFAULT_GLOBAL_PROXY_SETTINGS; + } + return value; +} + +function normalizeProjectOverride( + value: TerminalProxyOverride | null | undefined, +): TerminalProxyOverride { + if (!value) { + return DEFAULT_PROJECT_PROXY_OVERRIDE; + } + return value; +} + +export async function getDetectedInheritedProxy(params?: { + forceRefresh?: boolean; +}): Promise { + const shellEnv = params?.forceRefresh + ? await getShellEnvironment({ forceRefresh: true }) + : undefined; + const mergedEnv = await getProcessEnvWithShellEnv(process.env, shellEnv); + return detectInheritedProxyFromEnv(mergedEnv); +} + +export async function resolveEffectiveTerminalProxy(params: { + projectId: string; +}): Promise { + const project = localDb + .select({ terminalProxyOverride: projects.terminalProxyOverride }) + .from(projects) + .where(eq(projects.id, params.projectId)) + .get(); + + if (!project) { + return { state: "none", source: "none" }; + } + + const row = localDb.select().from(settings).get(); + const globalSettings = normalizeGlobalSettings(row?.terminalProxySettings); + const projectOverride = normalizeProjectOverride( + project.terminalProxyOverride, + ); + + const inheritedProxy = await getDetectedInheritedProxy(); + return resolveEffectiveTerminalProxyFromSettings({ + projectOverride, + globalSettings, + inheritedProxy, + }); +} + +export async function resolveEffectiveTerminalProxyForWorkspace(params: { + workspaceId: string; +}): Promise { + const workspace = localDb + .select({ projectId: workspaces.projectId }) + .from(workspaces) + .where(eq(workspaces.id, params.workspaceId)) + .get(); + + if (!workspace?.projectId) { + return { state: "none", source: "none" }; + } + + return resolveEffectiveTerminalProxy({ projectId: workspace.projectId }); +} + +export function applyTerminalProxyToEnv( + env: Record, + effectiveProxy: EffectiveTerminalProxy, +): Record { + const stripped = stripProxyEnvVars(env); + + if (effectiveProxy.state !== "manual" || !effectiveProxy.config) { + return stripped; + } + + if (effectiveProxy.httpProxy || effectiveProxy.httpsProxy) { + return { + ...stripped, + ...(effectiveProxy.httpProxy + ? { + HTTP_PROXY: effectiveProxy.httpProxy, + http_proxy: effectiveProxy.httpProxy, + } + : {}), + ...(effectiveProxy.httpsProxy + ? { + HTTPS_PROXY: effectiveProxy.httpsProxy, + https_proxy: effectiveProxy.httpsProxy, + } + : {}), + ...(effectiveProxy.config.noProxy + ? { + NO_PROXY: effectiveProxy.config.noProxy, + no_proxy: effectiveProxy.config.noProxy, + } + : {}), + }; + } + + return { + ...stripped, + ...buildProxyEnvVars(effectiveProxy.config), + }; +} + +export function getGlobalTerminalProxySettings(): TerminalProxySettings { + const row = localDb.select().from(settings).get(); + return normalizeGlobalSettings(row?.terminalProxySettings); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx index e177e43eee1..498e9dcd1c1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx @@ -53,6 +53,7 @@ import { } from "../../../../utils/settings-search"; import { ProjectSettingsHeader } from "../ProjectSettingsHeader"; import { ScriptsEditor } from "./components/ScriptsEditor"; +import { TerminalProxySection } from "./components/TerminalProxySection"; const REPO_DEFAULT_BASE_BRANCH = "__repo_default__"; @@ -292,6 +293,10 @@ export function ProjectSettings({ !branchData.branches.some( (branch) => branch.name === project.workspaceBaseBranch, ); + const showProjectTerminalProxy = isItemVisible( + SETTING_ITEM_ID.PROJECT_TERMINAL_PROXY, + visibleItems, + ); return (
@@ -567,6 +572,19 @@ export function ProjectSettings({
+ {showProjectTerminalProxy && ( + } + title="Terminal Proxy" + description="Configure project-level override for terminal proxy behavior." + > + + + )} +
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/TerminalProxySection/TerminalProxySection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/TerminalProxySection/TerminalProxySection.tsx new file mode 100644 index 00000000000..407c163718b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/TerminalProxySection/TerminalProxySection.tsx @@ -0,0 +1,270 @@ +import type { + TerminalProxyModeProject, + TerminalProxyOverride, + TerminalProxySettings, +} from "@superset/local-db"; +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { RadioGroup, RadioGroupItem } from "@superset/ui/radio-group"; +import { toast } from "@superset/ui/sonner"; +import { useEffect, useMemo, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { + getTerminalProxyStateLabel, + resolveEffectiveTerminalProxyFromSettings, +} from "shared/terminal-proxy"; + +interface TerminalProxySectionProps { + projectId: string; + currentOverride: TerminalProxyOverride | null | undefined; +} + +function normalizeOverride( + override: TerminalProxyOverride | null | undefined, +): TerminalProxyOverride { + return override ?? { mode: "inherit" }; +} + +function getManualFields(override: TerminalProxyOverride | null | undefined) { + return { + proxyUrl: override?.manual?.proxyUrl ?? "", + noProxy: override?.manual?.noProxy ?? "", + }; +} + +function getEffectiveStateLabel(params: { + currentOverride: TerminalProxyOverride | null | undefined; + globalSettings?: TerminalProxySettings | null; + detectedProxy?: { + hasProxy: boolean; + httpProxy?: string; + httpsProxy?: string; + noProxy?: string; + } | null; +}): string { + const projectOverride = normalizeOverride(params.currentOverride); + const globalMode = params.globalSettings?.mode ?? "auto"; + const effective = resolveEffectiveTerminalProxyFromSettings({ + projectOverride, + globalSettings: params.globalSettings, + inheritedProxy: { + hasProxy: params.detectedProxy?.hasProxy ?? false, + httpProxy: params.detectedProxy?.httpProxy, + httpsProxy: params.detectedProxy?.httpsProxy, + proxyUrl: + params.detectedProxy?.httpsProxy ?? params.detectedProxy?.httpProxy, + noProxy: params.detectedProxy?.noProxy, + }, + }); + + return getTerminalProxyStateLabel({ + projectMode: projectOverride.mode, + globalMode, + effective, + }); +} + +export function TerminalProxySection({ + projectId, + currentOverride, +}: TerminalProxySectionProps) { + const utils = electronTrpc.useUtils(); + const { data: globalProxySettings } = + electronTrpc.settings.getTerminalProxySettings.useQuery(); + const { data: detectedInheritedProxy } = + electronTrpc.settings.getDetectedInheritedProxy.useQuery(); + + const normalized = normalizeOverride(currentOverride); + const [mode, setMode] = useState(normalized.mode); + const [proxyUrl, setProxyUrl] = useState( + getManualFields(normalized).proxyUrl, + ); + const [noProxy, setNoProxy] = useState(getManualFields(normalized).noProxy); + + useEffect(() => { + if (!projectId) { + return; + } + const next = normalizeOverride(currentOverride); + setMode(next.mode); + const manual = getManualFields(next); + setProxyUrl(manual.proxyUrl); + setNoProxy(manual.noProxy); + }, [currentOverride, projectId]); + + const updateProject = electronTrpc.projects.update.useMutation({ + onMutate: () => ({ previousMode: mode }), + onSuccess: () => { + utils.projects.get.invalidate({ id: projectId }); + }, + onError: (error, _variables, context) => { + if (context?.previousMode) { + setMode(context.previousMode); + } + utils.projects.get.invalidate({ id: projectId }); + toast.error("Failed to save project terminal proxy", { + description: error.message, + }); + }, + }); + + const resetOverride = + electronTrpc.projects.resetTerminalProxyOverride.useMutation({ + onSuccess: () => { + setMode("inherit"); + setProxyUrl(""); + setNoProxy(""); + utils.projects.get.invalidate({ id: projectId }); + }, + onError: (error) => { + toast.error("Failed to reset project terminal proxy", { + description: error.message, + }); + }, + }); + + const effectiveStateLabel = useMemo( + () => + getEffectiveStateLabel({ + currentOverride, + globalSettings: globalProxySettings, + detectedProxy: detectedInheritedProxy, + }), + [currentOverride, globalProxySettings, detectedInheritedProxy], + ); + + const handleModeChange = (nextMode: TerminalProxyModeProject) => { + setMode(nextMode); + + if (nextMode === "enabled") { + return; + } + + updateProject.mutate({ + id: projectId, + patch: { + terminalProxyOverride: { + mode: nextMode, + }, + }, + }); + }; + + const saveManualOverride = () => { + updateProject.mutate({ + id: projectId, + patch: { + terminalProxyOverride: { + mode: "enabled", + manual: { + proxyUrl, + noProxy, + }, + }, + }, + }); + }; + + const isBusy = updateProject.isPending || resetOverride.isPending; + + return ( +
+
+ +

+ Project override has priority over global terminal proxy settings. +

+
+ + + handleModeChange(value as TerminalProxyModeProject) + } + disabled={isBusy} + className="space-y-2" + > +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + {mode === "enabled" && ( +
+
+ + setProxyUrl(event.target.value)} + placeholder="http://proxy.example.com:8080" + disabled={isBusy} + /> +
+
+ + setNoProxy(event.target.value)} + placeholder="localhost,127.0.0.1,.internal.example.com" + disabled={isBusy} + /> +
+
+ +
+
+ )} + +
+
+

Effective state

+

{effectiveStateLabel}

+

+ Changes apply only to newly created terminal sessions. +

+
+ +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/TerminalProxySection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/TerminalProxySection/index.ts new file mode 100644 index 00000000000..04914924ae4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/TerminalProxySection/index.ts @@ -0,0 +1 @@ +export { TerminalProxySection } from "./TerminalProxySection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx index 09581f20570..a3245390d2a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx @@ -7,6 +7,7 @@ import { import { LinkBehaviorSetting } from "./components/LinkBehaviorSetting"; import { PresetsSection } from "./components/PresetsSection"; import { SessionsSection } from "./components/SessionsSection"; +import { TerminalProxySection } from "./components/TerminalProxySection"; interface TerminalSettingsProps { visibleItems?: SettingItemId[] | null; @@ -60,6 +61,7 @@ export function TerminalSettings({ SETTING_ITEM_ID.TERMINAL_SESSIONS, visibleItems, ); + const showProxy = isItemVisible(SETTING_ITEM_ID.TERMINAL_PROXY, visibleItems); return (
@@ -82,6 +84,7 @@ export function TerminalSettings({ onPendingCreateProjectIdChange={onPendingCreateProjectIdChange} /> )} + {showProxy && } {showLinkBehavior && } {showSessions && } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/TerminalProxySection/TerminalProxySection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/TerminalProxySection/TerminalProxySection.tsx new file mode 100644 index 00000000000..76a343467f7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/TerminalProxySection/TerminalProxySection.tsx @@ -0,0 +1,209 @@ +import type { + TerminalProxyModeGlobal, + TerminalProxySettings, +} from "@superset/local-db"; +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { RadioGroup, RadioGroupItem } from "@superset/ui/radio-group"; +import { toast } from "@superset/ui/sonner"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +function getManualValues(settings?: TerminalProxySettings | null) { + return { + proxyUrl: settings?.manual?.proxyUrl ?? "", + noProxy: settings?.manual?.noProxy ?? "", + }; +} + +export function TerminalProxySection() { + const utils = electronTrpc.useUtils(); + const { data: terminalProxySettings } = + electronTrpc.settings.getTerminalProxySettings.useQuery(); + + const [detectedNonce, setDetectedNonce] = useState(0); + const detectedQuery = + electronTrpc.settings.getDetectedInheritedProxy.useQuery({ + refresh: detectedNonce > 0, + nonce: detectedNonce, + }); + + const [mode, setMode] = useState("auto"); + const [proxyUrl, setProxyUrl] = useState(""); + const [noProxy, setNoProxy] = useState(""); + const previousModeRef = useRef("auto"); + + useEffect(() => { + const nextMode = terminalProxySettings?.mode ?? "auto"; + previousModeRef.current = nextMode; + setMode(nextMode); + const manual = getManualValues(terminalProxySettings); + setProxyUrl(manual.proxyUrl); + setNoProxy(manual.noProxy); + }, [terminalProxySettings]); + + const setTerminalProxySettings = + electronTrpc.settings.setTerminalProxySettings.useMutation({ + onSuccess: () => { + utils.settings.getTerminalProxySettings.invalidate(); + }, + onError: (error) => { + setMode(previousModeRef.current); + utils.settings.getTerminalProxySettings.invalidate(); + toast.error("Failed to save terminal proxy settings", { + description: error.message, + }); + }, + }); + + const canSaveManual = useMemo( + () => proxyUrl.trim().length > 0 && !setTerminalProxySettings.isPending, + [proxyUrl, setTerminalProxySettings.isPending], + ); + const detectedValueFallback = "No values found"; + + const handleModeChange = (nextMode: TerminalProxyModeGlobal) => { + previousModeRef.current = mode; + setMode(nextMode); + + if (nextMode === "manual") { + return; + } + + setTerminalProxySettings.mutate({ + mode: nextMode, + }); + }; + + const saveManual = () => { + previousModeRef.current = mode; + setTerminalProxySettings.mutate({ + mode: "manual", + manual: { + proxyUrl, + noProxy, + }, + }); + }; + + return ( +
+
+ +

+ Configure proxy only for new terminal sessions and agent processes + launched from those terminals. +

+

+ Changes apply only to newly created terminals. Existing terminal + sessions keep their current environment. +

+
+ + + handleModeChange(value as TerminalProxyModeGlobal) + } + disabled={setTerminalProxySettings.isPending} + className="space-y-2" + > +
+ +
+ +

+ Use proxy variables detected from the environment used to launch + Superset. +

+
+
+
+ +
+ +

+ Use explicitly configured proxy URL for terminal sessions. +

+
+
+
+ +
+ +

+ Force-disable proxy variables for terminal sessions. +

+
+
+
+ + {mode === "manual" && ( +
+
+ + setProxyUrl(event.target.value)} + placeholder="http://proxy.example.com:8080" + disabled={setTerminalProxySettings.isPending} + /> +
+
+ + setNoProxy(event.target.value)} + placeholder="localhost,127.0.0.1,.internal.example.com" + disabled={setTerminalProxySettings.isPending} + /> +
+
+ +
+
+ )} + +
+
+ + +
+

+ These values are inherited from the environment used to launch + Superset. +

+
+
+
+ HTTP_PROXY= + {detectedQuery.data?.httpProxy || detectedValueFallback} +
+
+ HTTPS_PROXY= + {detectedQuery.data?.httpsProxy || detectedValueFallback} +
+
+ NO_PROXY= + {detectedQuery.data?.noProxy || detectedValueFallback} +
+
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/TerminalProxySection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/TerminalProxySection/index.ts new file mode 100644 index 00000000000..04914924ae4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/TerminalProxySection/index.ts @@ -0,0 +1 @@ +export { TerminalProxySection } from "./TerminalProxySection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index d7e56ed6276..4a9230f17dc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -39,6 +39,7 @@ export const SETTING_ITEM_ID = { TERMINAL_QUICK_ADD: "terminal-quick-add", TERMINAL_SESSIONS: "terminal-sessions", TERMINAL_LINK_BEHAVIOR: "terminal-link-behavior", + TERMINAL_PROXY: "terminal-proxy", MODELS_ANTHROPIC: "models-anthropic", MODELS_OPENAI: "models-openai", @@ -58,6 +59,7 @@ export const SETTING_ITEM_ID = { PROJECT_WORKTREE_LOCATION: "project-worktree-location", PROJECT_IMPORT_WORKTREES: "project-import-worktrees", PROJECT_ENV_VARS: "project-env-vars", + PROJECT_TERMINAL_PROXY: "project-terminal-proxy", API_KEYS_LIST: "api-keys-list", API_KEYS_GENERATE: "api-keys-generate", @@ -617,6 +619,24 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "browser", ], }, + { + id: SETTING_ITEM_ID.TERMINAL_PROXY, + section: "terminal", + title: "Terminal Proxy", + description: "Configure inherited, manual, or disabled proxy for terminals", + keywords: [ + "terminal", + "proxy", + "http_proxy", + "https_proxy", + "no_proxy", + "inherited", + "manual", + "disabled", + "network", + "agent", + ], + }, { id: SETTING_ITEM_ID.MODELS_ANTHROPIC, section: "models", @@ -862,6 +882,24 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "sandbox", ], }, + { + id: SETTING_ITEM_ID.PROJECT_TERMINAL_PROXY, + section: "project", + title: "Project Terminal Proxy", + description: "Override global terminal proxy for this project", + keywords: [ + "project", + "terminal", + "proxy", + "override", + "inherit", + "manual", + "disabled", + "no_proxy", + "http_proxy", + "https_proxy", + ], + }, { id: SETTING_ITEM_ID.API_KEYS_LIST, section: "apikeys", diff --git a/apps/desktop/src/shared/terminal-proxy-input.test.ts b/apps/desktop/src/shared/terminal-proxy-input.test.ts new file mode 100644 index 00000000000..afde50977b5 --- /dev/null +++ b/apps/desktop/src/shared/terminal-proxy-input.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "bun:test"; +import { + sanitizeTerminalProxyOverrideInput, + sanitizeTerminalProxySettingsInput, + terminalProxyOverrideInputSchema, + terminalProxySettingsInputSchema, +} from "./terminal-proxy-input"; + +describe("terminal proxy input helpers", () => { + it("sanitizes global manual settings and normalizes NO_PROXY", () => { + const parsed = terminalProxySettingsInputSchema.parse({ + mode: "manual", + manual: { + proxyUrl: "http://proxy.example.com:8080", + noProxy: " localhost, ,127.0.0.1 ", + }, + }); + + expect(sanitizeTerminalProxySettingsInput(parsed)).toEqual({ + mode: "manual", + manual: { + proxyUrl: "http://proxy.example.com:8080", + noProxy: "localhost,127.0.0.1", + }, + }); + }); + + it("sanitizes project enabled override and strips manual when disabled", () => { + const manualParsed = terminalProxyOverrideInputSchema.parse({ + mode: "enabled", + manual: { + proxyUrl: "http://proxy.example.com:8080", + }, + }); + expect(sanitizeTerminalProxyOverrideInput(manualParsed)).toEqual({ + mode: "enabled", + manual: { + proxyUrl: "http://proxy.example.com:8080", + }, + }); + + const disabledParsed = terminalProxyOverrideInputSchema.parse({ + mode: "disabled", + }); + expect(sanitizeTerminalProxyOverrideInput(disabledParsed)).toEqual({ + mode: "disabled", + }); + }); + + it("ignores stale manual payloads for non-manual global mode", () => { + const parsed = terminalProxySettingsInputSchema.parse({ + mode: "auto", + manual: { + proxyUrl: "not-a-url", + }, + }); + + expect(sanitizeTerminalProxySettingsInput(parsed)).toEqual({ + mode: "auto", + }); + }); + + it("ignores stale manual payloads for non-enabled project mode", () => { + const parsed = terminalProxyOverrideInputSchema.parse({ + mode: "inherit", + manual: { + proxyUrl: "not-a-url", + }, + }); + + expect(sanitizeTerminalProxyOverrideInput(parsed)).toEqual({ + mode: "inherit", + }); + }); + + it("rejects credentials in proxy URL at schema layer", () => { + expect(() => + terminalProxySettingsInputSchema.parse({ + mode: "manual", + manual: { + proxyUrl: "http://user:pass@proxy.example.com:8080", + }, + }), + ).toThrow( + "Proxy URLs with embedded credentials are not allowed; use secure storage", + ); + }); +}); diff --git a/apps/desktop/src/shared/terminal-proxy-input.ts b/apps/desktop/src/shared/terminal-proxy-input.ts new file mode 100644 index 00000000000..07d3b471def --- /dev/null +++ b/apps/desktop/src/shared/terminal-proxy-input.ts @@ -0,0 +1,90 @@ +import type { + TerminalProxyOverride, + TerminalProxySettings, +} from "@superset/local-db"; +import { z } from "zod"; +import { + hasProxyUrlCredentials, + normalizeNoProxyCsv, + PROXY_URL_CREDENTIALS_ERROR_MESSAGE, + validateTerminalProxyConfig, +} from "./terminal-proxy"; + +export const terminalProxyConfigInputSchema = z + .object({ + proxyUrl: z.string().trim().min(1), + noProxy: z.string().optional(), + }) + .superRefine((value, ctx) => { + if (hasProxyUrlCredentials(value.proxyUrl)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["proxyUrl"], + message: PROXY_URL_CREDENTIALS_ERROR_MESSAGE, + }); + } + }); + +export const terminalProxySettingsInputSchema = z.discriminatedUnion("mode", [ + z.object({ + mode: z.literal("auto"), + }), + z.object({ + mode: z.literal("disabled"), + }), + z.object({ + mode: z.literal("manual"), + manual: terminalProxyConfigInputSchema, + }), +]); + +export const terminalProxyOverrideInputSchema = z.discriminatedUnion("mode", [ + z.object({ + mode: z.literal("inherit"), + }), + z.object({ + mode: z.literal("disabled"), + }), + z.object({ + mode: z.literal("enabled"), + manual: terminalProxyConfigInputSchema, + }), +]); + +export function sanitizeTerminalProxySettingsInput( + input: z.infer, +): TerminalProxySettings { + if (input.mode !== "manual") { + return { mode: input.mode }; + } + + if (!input.manual) { + throw new Error("Manual proxy config is required in manual mode"); + } + + const manual = validateTerminalProxyConfig({ + proxyUrl: input.manual.proxyUrl, + noProxy: normalizeNoProxyCsv(input.manual.noProxy), + }); + + return { mode: "manual", manual }; +} + +export function sanitizeTerminalProxyOverrideInput( + input: z.infer, +): TerminalProxyOverride { + if (input.mode !== "enabled") { + return { mode: input.mode }; + } + + if (!input.manual) { + throw new Error("Manual proxy config is required when override is enabled"); + } + + const manual = validateTerminalProxyConfig({ + proxyUrl: input.manual.proxyUrl, + noProxy: normalizeNoProxyCsv(input.manual.noProxy), + }); + + return { mode: "enabled", manual }; +} diff --git a/apps/desktop/src/shared/terminal-proxy.test.ts b/apps/desktop/src/shared/terminal-proxy.test.ts new file mode 100644 index 00000000000..0d977395f1a --- /dev/null +++ b/apps/desktop/src/shared/terminal-proxy.test.ts @@ -0,0 +1,438 @@ +import { describe, expect, it } from "bun:test"; +import { + buildProxyEnvVars, + detectInheritedProxyFromEnv, + getTerminalProxyStateLabel, + maskProxyUrlCredentials, + normalizeNoProxyCsv, + resolveEffectiveTerminalProxyFromSettings, + stripProxyEnvVars, + validateTerminalProxyConfig, +} from "./terminal-proxy"; + +describe("terminal proxy utilities", () => { + it("normalizes NO_PROXY CSV by trimming and removing empty tokens", () => { + expect(normalizeNoProxyCsv(" localhost, ,127.0.0.1,, .internal ")).toBe( + "localhost,127.0.0.1,.internal", + ); + }); + + it("masks URL credentials for UI output", () => { + expect( + maskProxyUrlCredentials("http://user:pass@proxy.example.com:8080"), + ).toBe("http://***:***@proxy.example.com:8080/"); + }); + + it("masks malformed proxy credentials when URL parsing fails", () => { + expect(maskProxyUrlCredentials("user:pass@proxy.example.com:8080")).toBe( + "***:***@proxy.example.com:8080", + ); + expect(maskProxyUrlCredentials("user@proxy.example.com:8080")).toBe( + "***@proxy.example.com:8080", + ); + }); + + it("builds proxy env vars in upper and lower case", () => { + expect( + buildProxyEnvVars({ + proxyUrl: "http://proxy.example.com:8080", + noProxy: "localhost,127.0.0.1", + }), + ).toEqual({ + HTTP_PROXY: "http://proxy.example.com:8080", + HTTPS_PROXY: "http://proxy.example.com:8080", + http_proxy: "http://proxy.example.com:8080", + https_proxy: "http://proxy.example.com:8080", + NO_PROXY: "localhost,127.0.0.1", + no_proxy: "localhost,127.0.0.1", + }); + }); + + it("strips all proxy env vars", () => { + expect( + stripProxyEnvVars({ + PATH: "/usr/bin", + HTTP_PROXY: "http://proxy:8080", + HTTPS_PROXY: "http://proxy:8080", + NO_PROXY: "localhost", + http_proxy: "http://proxy:8080", + https_proxy: "http://proxy:8080", + no_proxy: "localhost", + ALL_PROXY: "socks5://proxy:1080", + }), + ).toEqual({ + PATH: "/usr/bin", + }); + }); + + it("detects inherited proxy and ignores NO_PROXY-only env", () => { + expect( + detectInheritedProxyFromEnv({ + NO_PROXY: "localhost", + }), + ).toEqual({ + noProxy: "localhost", + hasProxy: false, + }); + }); + + it("preserves distinct HTTP/HTTPS inherited values", () => { + expect( + detectInheritedProxyFromEnv({ + HTTP_PROXY: "http://http-proxy:8080", + HTTPS_PROXY: "http://https-proxy:8443", + NO_PROXY: " localhost , .internal ", + }), + ).toEqual({ + httpProxy: "http://http-proxy:8080", + httpsProxy: "http://https-proxy:8443", + proxyUrl: "http://https-proxy:8443", + noProxy: " localhost , .internal ", + hasProxy: true, + }); + }); + + it("falls back to lower-case inherited proxy keys when upper-case is blank", () => { + expect( + detectInheritedProxyFromEnv({ + HTTP_PROXY: "", + http_proxy: "http://http-proxy-lower:8080", + HTTPS_PROXY: " ", + https_proxy: "http://https-proxy-lower:8443", + NO_PROXY: "", + no_proxy: "localhost,.internal", + }), + ).toEqual({ + httpProxy: "http://http-proxy-lower:8080", + httpsProxy: "http://https-proxy-lower:8443", + proxyUrl: "http://https-proxy-lower:8443", + noProxy: "localhost,.internal", + hasProxy: true, + }); + }); + + it("rejects invalid manual proxy URL during validation", () => { + expect(() => + validateTerminalProxyConfig({ + proxyUrl: "not-a-url", + }), + ).toThrow("Proxy URL must be a valid http(s)/socks URL"); + }); + + it("rejects manual proxy URLs with embedded credentials", () => { + expect(() => + validateTerminalProxyConfig({ + proxyUrl: "http://user:pass@proxy.example.com:8080", + }), + ).toThrow( + "Proxy URLs with embedded credentials are not allowed; use secure storage", + ); + }); + + it("allows embedded credentials when explicitly validating inherited config", () => { + expect( + validateTerminalProxyConfig( + { + proxyUrl: "http://user:pass@proxy.example.com:8080", + }, + { allowCredentials: true }, + ), + ).toEqual({ + proxyUrl: "http://user:pass@proxy.example.com:8080", + }); + }); + + it("builds proxy env vars for authenticated inherited proxy URLs", () => { + expect( + buildProxyEnvVars({ + proxyUrl: "http://user:pass@proxy.example.com:8080", + }), + ).toEqual({ + HTTP_PROXY: "http://user:pass@proxy.example.com:8080", + HTTPS_PROXY: "http://user:pass@proxy.example.com:8080", + http_proxy: "http://user:pass@proxy.example.com:8080", + https_proxy: "http://user:pass@proxy.example.com:8080", + }); + }); +}); + +describe("resolveEffectiveTerminalProxyFromSettings", () => { + const inherited = detectInheritedProxyFromEnv({ + HTTP_PROXY: "http://inherited-proxy:8080", + NO_PROXY: "localhost", + }); + + it("project disabled always wins", () => { + expect( + resolveEffectiveTerminalProxyFromSettings({ + projectOverride: { mode: "disabled" }, + globalSettings: { + mode: "manual", + manual: { proxyUrl: "http://g:8080" }, + }, + inheritedProxy: inherited, + }), + ).toEqual({ + state: "disabled", + source: "project", + }); + }); + + it("project disabled ignores inherited proxy even when global is auto", () => { + expect( + resolveEffectiveTerminalProxyFromSettings({ + projectOverride: { mode: "disabled" }, + globalSettings: { mode: "auto" }, + inheritedProxy: inherited, + }), + ).toEqual({ + state: "disabled", + source: "project", + }); + }); + + it("project enabled manual wins over global", () => { + expect( + resolveEffectiveTerminalProxyFromSettings({ + projectOverride: { + mode: "enabled", + manual: { + proxyUrl: "http://project-proxy:8080", + noProxy: "localhost", + }, + }, + globalSettings: { mode: "auto" }, + inheritedProxy: inherited, + }), + ).toEqual({ + state: "manual", + source: "project", + config: { proxyUrl: "http://project-proxy:8080", noProxy: "localhost" }, + }); + }); + + it("project enabled applies manual even when global has no proxy", () => { + expect( + resolveEffectiveTerminalProxyFromSettings({ + projectOverride: { + mode: "enabled", + manual: { + proxyUrl: "http://project-proxy:8080", + }, + }, + globalSettings: { mode: "auto" }, + inheritedProxy: { hasProxy: false }, + }), + ).toEqual({ + state: "manual", + source: "project", + config: { proxyUrl: "http://project-proxy:8080" }, + }); + }); + + it("inherit + global disabled resolves to disabled", () => { + expect( + resolveEffectiveTerminalProxyFromSettings({ + projectOverride: { mode: "inherit" }, + globalSettings: { mode: "disabled" }, + inheritedProxy: inherited, + }), + ).toEqual({ + state: "disabled", + source: "global-disabled", + }); + }); + + it("inherit + global manual uses global manual", () => { + expect( + resolveEffectiveTerminalProxyFromSettings({ + projectOverride: { mode: "inherit" }, + globalSettings: { + mode: "manual", + manual: { + proxyUrl: "http://global-proxy:8080", + noProxy: "localhost", + }, + }, + inheritedProxy: inherited, + }), + ).toEqual({ + state: "manual", + source: "global-manual", + config: { proxyUrl: "http://global-proxy:8080", noProxy: "localhost" }, + }); + }); + + it("inherit + global manual ignores inherited proxy values", () => { + expect( + resolveEffectiveTerminalProxyFromSettings({ + projectOverride: { mode: "inherit" }, + globalSettings: { + mode: "manual", + manual: { proxyUrl: "http://global-proxy:8080" }, + }, + inheritedProxy: { + hasProxy: true, + proxyUrl: "http://inherited-proxy:8080", + noProxy: "localhost", + }, + }), + ).toEqual({ + state: "manual", + source: "global-manual", + config: { proxyUrl: "http://global-proxy:8080" }, + }); + }); + + it("inherit + global auto uses inherited when present", () => { + expect( + resolveEffectiveTerminalProxyFromSettings({ + projectOverride: { mode: "inherit" }, + globalSettings: { mode: "auto" }, + inheritedProxy: inherited, + }), + ).toEqual({ + state: "manual", + source: "global-auto", + config: { proxyUrl: "http://inherited-proxy:8080", noProxy: "localhost" }, + httpProxy: "http://inherited-proxy:8080", + }); + }); + + it("inherit + global auto falls back to httpProxy without proxyUrl", () => { + expect( + resolveEffectiveTerminalProxyFromSettings({ + projectOverride: { mode: "inherit" }, + globalSettings: { mode: "auto" }, + inheritedProxy: { + hasProxy: true, + httpProxy: "http://inherited-http-proxy:8080", + }, + }), + ).toEqual({ + state: "manual", + source: "global-auto", + config: { proxyUrl: "http://inherited-http-proxy:8080" }, + httpProxy: "http://inherited-http-proxy:8080", + }); + }); + + it("inherit + global auto keeps inherited authenticated proxy URLs", () => { + expect( + resolveEffectiveTerminalProxyFromSettings({ + projectOverride: { mode: "inherit" }, + globalSettings: { mode: "auto" }, + inheritedProxy: { + hasProxy: true, + proxyUrl: "http://user:pass@inherited-proxy:8080", + }, + }), + ).toEqual({ + state: "manual", + source: "global-auto", + config: { proxyUrl: "http://user:pass@inherited-proxy:8080" }, + httpProxy: "http://user:pass@inherited-proxy:8080", + httpsProxy: "http://user:pass@inherited-proxy:8080", + }); + }); + + it("inherit + global auto accepts valid HTTP_PROXY when HTTPS_PROXY is invalid", () => { + expect( + resolveEffectiveTerminalProxyFromSettings({ + projectOverride: { mode: "inherit" }, + globalSettings: { mode: "auto" }, + inheritedProxy: { + hasProxy: true, + httpProxy: "http://inherited-http-proxy:8080", + httpsProxy: "bad-url", + noProxy: " localhost , .internal ", + }, + }), + ).toEqual({ + state: "manual", + source: "global-auto", + config: { + proxyUrl: "http://inherited-http-proxy:8080", + noProxy: "localhost,.internal", + }, + httpProxy: "http://inherited-http-proxy:8080", + }); + }); + + it("inherit + global auto without inherited proxy resolves to none", () => { + expect( + resolveEffectiveTerminalProxyFromSettings({ + projectOverride: { mode: "inherit" }, + globalSettings: { mode: "auto" }, + inheritedProxy: { hasProxy: false }, + }), + ).toEqual({ + state: "none", + source: "none", + }); + }); + + it("inherit + global auto resolves to none when both HTTP_PROXY and HTTPS_PROXY are invalid", () => { + expect( + resolveEffectiveTerminalProxyFromSettings({ + projectOverride: { mode: "inherit" }, + globalSettings: { mode: "auto" }, + inheritedProxy: { + hasProxy: true, + httpProxy: "not-a-url", + httpsProxy: "also-bad", + }, + }), + ).toEqual({ + state: "none", + source: "none", + }); + }); + + it("invalid project manual proxy resolves to none", () => { + expect( + resolveEffectiveTerminalProxyFromSettings({ + projectOverride: { + mode: "enabled", + manual: { proxyUrl: "not-a-url" }, + }, + globalSettings: { + mode: "manual", + manual: { proxyUrl: "http://g:8080" }, + }, + inheritedProxy: inherited, + }), + ).toEqual({ + state: "none", + source: "none", + }); + }); + + it("invalid global manual proxy resolves to none", () => { + expect( + resolveEffectiveTerminalProxyFromSettings({ + projectOverride: { mode: "inherit" }, + globalSettings: { mode: "manual", manual: { proxyUrl: "bad-url" } }, + inheritedProxy: inherited, + }), + ).toEqual({ + state: "none", + source: "none", + }); + }); +}); + +describe("getTerminalProxyStateLabel", () => { + it("returns explicit label for inherit + global disabled", () => { + expect( + getTerminalProxyStateLabel({ + projectMode: "inherit", + globalMode: "disabled", + effective: { + state: "disabled", + source: "global-disabled", + }, + }), + ).toBe("Proxy disabled (global setting)"); + }); +}); diff --git a/apps/desktop/src/shared/terminal-proxy.ts b/apps/desktop/src/shared/terminal-proxy.ts new file mode 100644 index 00000000000..8e2e9014354 --- /dev/null +++ b/apps/desktop/src/shared/terminal-proxy.ts @@ -0,0 +1,372 @@ +import type { + TerminalProxyConfig, + TerminalProxyModeGlobal, + TerminalProxyModeProject, + TerminalProxyOverride, + TerminalProxySettings, +} from "@superset/local-db"; +import { z } from "zod"; + +export const PROXY_ENV_KEYS = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "ALL_PROXY", + "FTP_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "all_proxy", + "ftp_proxy", +] as const; + +const VALID_PROXY_PROTOCOLS = new Set([ + "http:", + "https:", + "socks:", + "socks4:", + "socks5:", +]); + +export const PROXY_URL_CREDENTIALS_ERROR_MESSAGE = + "Proxy URLs with embedded credentials are not allowed; use secure storage"; + +export function hasProxyUrlCredentials(value: string): boolean { + try { + const parsed = new URL(value); + return parsed.username.length > 0 || parsed.password.length > 0; + } catch { + return false; + } +} + +const proxyUrlSchema = z + .string() + .trim() + .min(1, "Proxy URL is required") + .refine((value) => { + try { + const parsed = new URL(value); + return ( + VALID_PROXY_PROTOCOLS.has(parsed.protocol) && parsed.hostname.length > 0 + ); + } catch { + return false; + } + }, "Proxy URL must be a valid http(s)/socks URL"); + +const manualProxyUrlSchema = proxyUrlSchema.refine( + (value) => !hasProxyUrlCredentials(value), + PROXY_URL_CREDENTIALS_ERROR_MESSAGE, +); + +const noProxySchema = z + .string() + .optional() + .transform((value) => normalizeNoProxyCsv(value)); + +const terminalProxyConfigInputSchema = z.object({ + proxyUrl: manualProxyUrlSchema, + noProxy: noProxySchema, +}); + +const inheritedTerminalProxyConfigInputSchema = z.object({ + proxyUrl: proxyUrlSchema, + noProxy: noProxySchema, +}); + +export function normalizeNoProxyCsv(value?: string | null): string | undefined { + if (!value) return undefined; + + const normalized = value + .split(",") + .map((token) => token.trim()) + .filter((token) => token.length > 0) + .join(","); + + return normalized.length > 0 ? normalized : undefined; +} + +export function validateTerminalProxyConfig( + input: TerminalProxyConfig, + options?: { allowCredentials?: boolean }, +): TerminalProxyConfig { + const schema = options?.allowCredentials + ? inheritedTerminalProxyConfigInputSchema + : terminalProxyConfigInputSchema; + const parsed = schema.parse(input); + return { + proxyUrl: parsed.proxyUrl, + ...(parsed.noProxy ? { noProxy: parsed.noProxy } : {}), + }; +} + +export function maskProxyUrlCredentials(urlValue: string): string { + try { + const parsed = new URL(urlValue); + if (!parsed.username && !parsed.password) { + const sanitized = sanitizeMalformedProxyCredentials(urlValue); + if (sanitized !== urlValue) { + return sanitized; + } + return parsed.toString(); + } + parsed.username = parsed.username ? "***" : ""; + parsed.password = parsed.password ? "***" : ""; + return parsed.toString(); + } catch { + return sanitizeMalformedProxyCredentials(urlValue); + } +} + +function sanitizeMalformedProxyCredentials(urlValue: string): string { + const userPassRedacted = urlValue.replace( + /^([a-zA-Z][a-zA-Z\d+.-]*:\/\/)?([^/\s@:#]+):([^/\s@]+)@/, + (_match, scheme = "") => `${scheme}***:***@`, + ); + if (userPassRedacted !== urlValue) { + return userPassRedacted; + } + + return urlValue.replace( + /^([a-zA-Z][a-zA-Z\d+.-]*:\/\/)?([^/\s@]+)@/, + (_match, scheme = "") => `${scheme}***@`, + ); +} + +export function stripProxyEnvVars( + env: Record, +): Record { + const result: Record = { ...env }; + for (const key of PROXY_ENV_KEYS) { + delete result[key]; + } + return result; +} + +export function buildProxyEnvVars( + config: TerminalProxyConfig, +): Record { + const validated = validateTerminalProxyConfig(config, { + allowCredentials: true, + }); + const env: Record = { + HTTP_PROXY: validated.proxyUrl, + HTTPS_PROXY: validated.proxyUrl, + http_proxy: validated.proxyUrl, + https_proxy: validated.proxyUrl, + }; + + if (validated.noProxy) { + env.NO_PROXY = validated.noProxy; + env.no_proxy = validated.noProxy; + } + + return env; +} + +export interface DetectedInheritedProxy { + httpProxy?: string; + httpsProxy?: string; + noProxy?: string; + proxyUrl?: string; + hasProxy: boolean; +} + +function pickPreferredEnvValue( + upperValue: string | undefined, + lowerValue: string | undefined, +): string | undefined { + const upperHasValue = + typeof upperValue === "string" && upperValue.trim().length > 0; + if (upperHasValue) { + return upperValue; + } + + const lowerHasValue = + typeof lowerValue === "string" && lowerValue.trim().length > 0; + if (lowerHasValue) { + return lowerValue; + } + + if (upperValue !== undefined) { + return upperValue; + } + + return lowerValue; +} + +export function detectInheritedProxyFromEnv( + env: Record, +): DetectedInheritedProxy { + const httpProxy = pickPreferredEnvValue(env.HTTP_PROXY, env.http_proxy); + const httpsProxy = pickPreferredEnvValue(env.HTTPS_PROXY, env.https_proxy); + const noProxy = pickPreferredEnvValue(env.NO_PROXY, env.no_proxy); + const hasHttpProxy = + typeof httpProxy === "string" && httpProxy.trim().length > 0; + const hasHttpsProxy = + typeof httpsProxy === "string" && httpsProxy.trim().length > 0; + const proxyUrl = hasHttpsProxy + ? httpsProxy + : hasHttpProxy + ? httpProxy + : undefined; + + return { + hasProxy: hasHttpProxy || hasHttpsProxy, + ...(proxyUrl !== undefined ? { proxyUrl } : {}), + ...(httpProxy !== undefined ? { httpProxy } : {}), + ...(httpsProxy !== undefined ? { httpsProxy } : {}), + ...(noProxy !== undefined ? { noProxy } : {}), + }; +} + +export type EffectiveTerminalProxyState = "disabled" | "manual" | "none"; +export type EffectiveTerminalProxySource = + | "project" + | "global-manual" + | "global-auto" + | "global-disabled" + | "none"; + +export interface EffectiveTerminalProxy { + state: EffectiveTerminalProxyState; + source: EffectiveTerminalProxySource; + config?: TerminalProxyConfig; + httpProxy?: string; + httpsProxy?: string; +} + +function toManualConfigOrNull( + config?: TerminalProxyConfig, + options?: { allowCredentials?: boolean }, +): TerminalProxyConfig | null { + if (!config) return null; + try { + return validateTerminalProxyConfig(config, options); + } catch { + return null; + } +} + +function toValidatedInheritedProxyUrl( + proxyUrl: string | undefined, + noProxy: string | undefined, +): string | null { + if (typeof proxyUrl !== "string" || proxyUrl.trim().length === 0) { + return null; + } + + const validated = toManualConfigOrNull( + { + proxyUrl, + ...(noProxy ? { noProxy } : {}), + }, + { allowCredentials: true }, + ); + return validated?.proxyUrl ?? null; +} + +export function resolveEffectiveTerminalProxyFromSettings(params: { + projectOverride?: TerminalProxyOverride | null; + globalSettings?: TerminalProxySettings | null; + inheritedProxy: DetectedInheritedProxy; +}): EffectiveTerminalProxy { + const projectOverride = params.projectOverride ?? { mode: "inherit" }; + const globalSettings = params.globalSettings ?? { mode: "auto" }; + + if (projectOverride.mode === "disabled") { + return { state: "disabled", source: "project" }; + } + + if (projectOverride.mode === "enabled") { + const projectManual = toManualConfigOrNull(projectOverride.manual); + if (!projectManual) { + return { state: "none", source: "none" }; + } + return { state: "manual", source: "project", config: projectManual }; + } + + if (globalSettings.mode === "disabled") { + return { state: "disabled", source: "global-disabled" }; + } + + if (globalSettings.mode === "manual") { + const globalManual = toManualConfigOrNull(globalSettings.manual); + if (!globalManual) { + return { state: "none", source: "none" }; + } + return { state: "manual", source: "global-manual", config: globalManual }; + } + + const inheritedNoProxy = normalizeNoProxyCsv(params.inheritedProxy.noProxy); + const validatedHttpsProxy = toValidatedInheritedProxyUrl( + params.inheritedProxy.httpsProxy, + inheritedNoProxy, + ); + const validatedHttpProxy = toValidatedInheritedProxyUrl( + params.inheritedProxy.httpProxy, + inheritedNoProxy, + ); + const validatedFallbackProxy = toValidatedInheritedProxyUrl( + params.inheritedProxy.proxyUrl, + inheritedNoProxy, + ); + const effectiveHttpsProxy = + validatedHttpsProxy ?? + (validatedHttpProxy || !validatedFallbackProxy + ? null + : validatedFallbackProxy); + const effectiveHttpProxy = + validatedHttpProxy ?? + (validatedHttpsProxy || !validatedFallbackProxy + ? null + : validatedFallbackProxy); + const preferredProxyUrl = effectiveHttpsProxy ?? effectiveHttpProxy; + + if (!params.inheritedProxy.hasProxy || !preferredProxyUrl) { + return { state: "none", source: "none" }; + } + + return { + state: "manual", + source: "global-auto", + config: { + proxyUrl: preferredProxyUrl, + ...(inheritedNoProxy ? { noProxy: inheritedNoProxy } : {}), + }, + ...(effectiveHttpProxy ? { httpProxy: effectiveHttpProxy } : {}), + ...(effectiveHttpsProxy ? { httpsProxy: effectiveHttpsProxy } : {}), + }; +} + +export function getTerminalProxyStateLabel(params: { + projectMode: TerminalProxyModeProject; + globalMode: TerminalProxyModeGlobal; + effective: EffectiveTerminalProxy; +}): string { + if (params.projectMode === "disabled") { + return "Proxy disabled for this project"; + } + if (params.projectMode === "enabled" && params.effective.state === "manual") { + return "Using project manual proxy"; + } + if ( + params.projectMode === "inherit" && + params.globalMode === "manual" && + params.effective.state === "manual" + ) { + return "Using global (manual)"; + } + if ( + params.projectMode === "inherit" && + params.globalMode === "auto" && + params.effective.state === "manual" + ) { + return "Using global (inherited)"; + } + if (params.projectMode === "inherit" && params.globalMode === "disabled") { + return "Proxy disabled (global setting)"; + } + return "No proxy configured"; +} diff --git a/packages/local-db/drizzle/0039_add_terminal_proxy_settings.sql b/packages/local-db/drizzle/0039_add_terminal_proxy_settings.sql new file mode 100644 index 00000000000..1e66e372451 --- /dev/null +++ b/packages/local-db/drizzle/0039_add_terminal_proxy_settings.sql @@ -0,0 +1,2 @@ +ALTER TABLE `projects` ADD `terminal_proxy_override` text;--> statement-breakpoint +ALTER TABLE `settings` ADD `terminal_proxy_settings` text; \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/0039_snapshot.json b/packages/local-db/drizzle/meta/0039_snapshot.json new file mode 100644 index 00000000000..f7ccb51afc8 --- /dev/null +++ b/packages/local-db/drizzle/meta/0039_snapshot.json @@ -0,0 +1,1411 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1ae34cc4-4ba6-464d-9128-462c9a302e15", + "prevId": "1cf43ce9-9e0f-44c4-b4cf-5cafe80bf78d", + "tables": { + "browser_history": { + "name": "browser_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_visited_at": { + "name": "last_visited_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visit_count": { + "name": "visit_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "browser_history_url_unique": { + "name": "browser_history_url_unique", + "columns": [ + "url" + ], + "isUnique": true + }, + "browser_history_url_idx": { + "name": "browser_history_url_idx", + "columns": [ + "url" + ], + "isUnique": false + }, + "browser_history_last_visited_at_idx": { + "name": "browser_history_last_visited_at_idx", + "columns": [ + "last_visited_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_members_organization_id_idx": { + "name": "organization_members_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "organization_members_user_id_idx": { + "name": "organization_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_org_id": { + "name": "clerk_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_clerk_org_id_unique": { + "name": "organizations_clerk_org_id_unique", + "columns": [ + "clerk_org_id" + ], + "isUnique": true + }, + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "organizations_clerk_org_id_idx": { + "name": "organizations_clerk_org_id_idx", + "columns": [ + "clerk_org_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "main_repo_path": { + "name": "main_repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_toast_dismissed": { + "name": "config_toast_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_base_branch": { + "name": "workspace_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_owner": { + "name": "github_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hide_image": { + "name": "hide_image", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "neon_project_id": { + "name": "neon_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_app": { + "name": "default_app", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_proxy_override": { + "name": "terminal_proxy_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_main_repo_path_idx": { + "name": "projects_main_repo_path_idx", + "columns": [ + "main_repo_path" + ], + "isUnique": false + }, + "projects_last_opened_at_idx": { + "name": "projects_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets": { + "name": "terminal_presets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets_initialized": { + "name": "terminal_presets_initialized", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_preset_overrides": { + "name": "agent_preset_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_custom_definitions": { + "name": "agent_custom_definitions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_ringtone_id": { + "name": "selected_ringtone_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_on_quit": { + "name": "confirm_on_quit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_link_behavior": { + "name": "terminal_link_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "persist_terminal": { + "name": "persist_terminal", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "auto_apply_default_preset": { + "name": "auto_apply_default_preset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_sounds_muted": { + "name": "notification_sounds_muted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_volume": { + "name": "notification_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delete_local_branch": { + "name": "delete_local_branch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_open_mode": { + "name": "file_open_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_presets_bar": { + "name": "show_presets_bar", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_compact_terminal_add_button": { + "name": "use_compact_terminal_add_button", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_font_family": { + "name": "terminal_font_family", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_font_size": { + "name": "terminal_font_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "editor_font_family": { + "name": "editor_font_family", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "editor_font_size": { + "name": "editor_font_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_resource_monitor": { + "name": "show_resource_monitor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_links_in_app": { + "name": "open_links_in_app", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_editor": { + "name": "default_editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_proxy_settings": { + "name": "terminal_proxy_settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_color": { + "name": "status_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_type": { + "name": "status_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_position": { + "name": "status_position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_provider": { + "name": "external_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tasks_slug_unique": { + "name": "tasks_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_id": { + "name": "clerk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_clerk_id_unique": { + "name": "users_clerk_id_unique", + "columns": [ + "clerk_id" + ], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "users_clerk_id_idx": { + "name": "users_clerk_id_idx", + "columns": [ + "clerk_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_sections": { + "name": "workspace_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_collapsed": { + "name": "is_collapsed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_sections_project_id_idx": { + "name": "workspace_sections_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_sections_project_id_projects_id_fk": { + "name": "workspace_sections_project_id_projects_id_fk", + "tableFrom": "workspace_sections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_unread": { + "name": "is_unread", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_unnamed": { + "name": "is_unnamed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "deleting_at": { + "name": "deleting_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_base": { + "name": "port_base", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_worktree_id_idx": { + "name": "workspaces_worktree_id_idx", + "columns": [ + "worktree_id" + ], + "isUnique": false + }, + "workspaces_last_opened_at_idx": { + "name": "workspaces_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + }, + "workspaces_section_id_idx": { + "name": "workspaces_section_id_idx", + "columns": [ + "section_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_worktree_id_worktrees_id_fk": { + "name": "workspaces_worktree_id_worktrees_id_fk", + "tableFrom": "workspaces", + "tableTo": "worktrees", + "columnsFrom": [ + "worktree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_section_id_workspace_sections_id_fk": { + "name": "workspaces_section_id_workspace_sections_id_fk", + "tableFrom": "workspaces", + "tableTo": "workspace_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_status": { + "name": "git_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_status": { + "name": "github_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_superset": { + "name": "created_by_superset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "worktrees_project_id_idx": { + "name": "worktrees_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "worktrees_branch_idx": { + "name": "worktrees_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + } + }, + "foreignKeys": { + "worktrees_project_id_projects_id_fk": { + "name": "worktrees_project_id_projects_id_fk", + "tableFrom": "worktrees", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json index 32a534ca05f..d76b4ec2569 100644 --- a/packages/local-db/drizzle/meta/_journal.json +++ b/packages/local-db/drizzle/meta/_journal.json @@ -274,6 +274,13 @@ "when": 1774995017185, "tag": "0038_quick_star_brand", "breakpoints": true + }, + { + "idx": 39, + "version": "6", + "when": 1774300724542, + "tag": "0039_add_terminal_proxy_settings", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index 57cb6f88497..232d5d58c64 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -11,6 +11,8 @@ import type { GitStatus, TerminalLinkBehavior, TerminalPreset, + TerminalProxyOverride, + TerminalProxySettings, WorkspaceType, } from "./zod"; @@ -46,6 +48,9 @@ export const projects = sqliteTable( iconUrl: text("icon_url"), neonProjectId: text("neon_project_id"), defaultApp: text("default_app").$type(), + terminalProxyOverride: text("terminal_proxy_override", { + mode: "json", + }).$type(), }, (table) => [ index("projects_main_repo_path_idx").on(table.mainRepoPath), @@ -222,6 +227,9 @@ export const settings = sqliteTable("settings", { worktreeBaseDir: text("worktree_base_dir"), openLinksInApp: integer("open_links_in_app", { mode: "boolean" }), defaultEditor: text("default_editor").$type(), + terminalProxySettings: text("terminal_proxy_settings", { + mode: "json", + }).$type(), }); export type InsertSettings = typeof settings.$inferInsert; diff --git a/packages/local-db/src/schema/zod.ts b/packages/local-db/src/schema/zod.ts index debfb2e2657..6a7f18bb123 100644 --- a/packages/local-db/src/schema/zod.ts +++ b/packages/local-db/src/schema/zod.ts @@ -248,3 +248,62 @@ export type BranchPrefixMode = (typeof BRANCH_PREFIX_MODES)[number]; export const FILE_OPEN_MODES = ["split-pane", "new-tab"] as const; export type FileOpenMode = (typeof FILE_OPEN_MODES)[number]; + +export const TERMINAL_PROXY_MODE_GLOBAL = [ + "auto", + "manual", + "disabled", +] as const; + +export type TerminalProxyModeGlobal = + (typeof TERMINAL_PROXY_MODE_GLOBAL)[number]; + +export const TERMINAL_PROXY_MODE_PROJECT = [ + "inherit", + "enabled", + "disabled", +] as const; + +export type TerminalProxyModeProject = + (typeof TERMINAL_PROXY_MODE_PROJECT)[number]; + +export const terminalProxyConfigSchema = z.object({ + proxyUrl: z.string(), + noProxy: z.string().optional(), +}); + +export type TerminalProxyConfig = z.infer; + +export const terminalProxySettingsSchema = z.discriminatedUnion("mode", [ + z.object({ + mode: z.literal("manual"), + manual: terminalProxyConfigSchema, + }), + z.object({ + mode: z.literal("auto"), + manual: z.never().optional(), + }), + z.object({ + mode: z.literal("disabled"), + manual: z.never().optional(), + }), +]); + +export type TerminalProxySettings = z.infer; + +export const terminalProxyOverrideSchema = z.discriminatedUnion("mode", [ + z.object({ + mode: z.literal("enabled"), + manual: terminalProxyConfigSchema, + }), + z.object({ + mode: z.literal("inherit"), + manual: z.never().optional(), + }), + z.object({ + mode: z.literal("disabled"), + manual: z.never().optional(), + }), +]); + +export type TerminalProxyOverride = z.infer;