Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
119da27
feat(desktop): add terminal proxy settings with inherited env detecti…
vivishko Mar 24, 2026
aa4b289
fix(desktop): reject credential-bearing proxy URLs in settings/projec…
vivishko Mar 25, 2026
8161a10
fix(desktop): derive effective proxy label from persisted override
vivishko Mar 25, 2026
0664c01
fix(desktop): per-key inherited proxy rendering and rollback on mutat…
vivishko Mar 25, 2026
779ee0c
fix(desktop): tolerate proxy resolution failures and skip resolver fo…
vivishko Mar 25, 2026
3250d23
fix(local-db): enforce terminal proxy mode/manual invariants with dis…
vivishko Mar 25, 2026
c306a6f
fix(desktop): preserve inherited auth proxies, always resolve proxy e…
vivishko Mar 25, 2026
a1fb905
fix(desktop): align proxy UX/state labels and harden project proxy ro…
vivishko Mar 25, 2026
a7dc339
fix(desktop): sync terminal proxy reset UI, redact malformed creds, p…
vivishko Mar 25, 2026
7364dfb
fix(desktop): fallback to lowercase env keys when uppercase is blank
vivishko Mar 25, 2026
b29d1bc
fix(desktop): align terminal proxy placeholder with credential policy
vivishko Mar 25, 2026
20ca7d8
refactor(desktop): extract shared terminal proxy input schemas and sa…
vivishko Mar 25, 2026
b741ea7
refactor(desktop): remove dead fallback branch in terminal proxy env …
vivishko Mar 25, 2026
2242919
perf(desktop): skip effective proxy resolution when attaching to exis…
vivishko Mar 25, 2026
968604b
fix(desktop): reset proxy drafts on project switch and validate manua…
vivishko Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
}),
}),
)
Expand All @@ -1421,6 +1428,30 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
throw new Error(`Project ${input.id} not found`);
}

let terminalProxyOverridePatch:
| { terminalProxyOverride: null | ReturnType<typeof sanitizeTerminalProxyOverrideInput> }
| 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({
Expand All @@ -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))
Expand Down
68 changes: 67 additions & 1 deletion apps/desktop/src/lib/trpc/routers/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
settings,
TERMINAL_LINK_BEHAVIORS,
type TerminalPreset,
type TerminalProxySettings,
} from "@superset/local-db";
import {
AGENT_PRESET_COMMANDS,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -168,7 +180,6 @@ function clearCustomAgentPresetOverride(id: `custom:${string}`) {
}),
);
}

function getResolvedAgentPresets() {
return resolveAgentConfigs({
customDefinitions: readRawAgentCustomDefinitions(),
Expand Down Expand Up @@ -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 }) => {
Expand Down
133 changes: 130 additions & 3 deletions apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
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,
} from "../errors";
import type { SessionInfo } from "./types";

class MockTerminalHostClient extends EventEmitter {
createOrAttachCalls: Array<{ sessionId: string; requestId?: string }> = [];
createOrAttachCalls: Array<{
sessionId: string;
requestId?: string;
env?: Record<string, string>;
}> = [];
cancelCreateOrAttachCalls: Array<{ sessionId: string; requestId: string }> =
[];
killCalls: Array<{ sessionId: string; deleteHistory?: boolean }> = [];
Expand Down Expand Up @@ -69,7 +74,11 @@ class MockTerminalHostClient extends EventEmitter {
}

async createOrAttach(
params: { sessionId: string; requestId?: string },
params: {
sessionId: string;
requestId?: string;
env?: Record<string, string>;
},
signal?: AbortSignal,
) {
if (this.createOrAttachGate) {
Expand Down Expand Up @@ -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,
Expand All @@ -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<string, string>,
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 },
}));
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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<string>;
};
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<string>;
};
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 });
});
});
Loading