diff --git a/apps/desktop/src/main/host-service/index.ts b/apps/desktop/src/main/host-service/index.ts index 88f85fe793e..673e87111d3 100644 --- a/apps/desktop/src/main/host-service/index.ts +++ b/apps/desktop/src/main/host-service/index.ts @@ -17,100 +17,111 @@ import { LocalGitCredentialProvider, PskHostAuthProvider, } from "@superset/host-service"; +import { + initTerminalBaseEnv, + resolveTerminalBaseEnv, +} from "@superset/host-service/terminal-env"; import { HOST_SERVICE_PROTOCOL_VERSION, removeManifest, writeManifest, } from "main/lib/host-service-manifest"; -const authToken = process.env.AUTH_TOKEN; -const cloudApiUrl = process.env.CLOUD_API_URL; -const dbPath = process.env.HOST_DB_PATH; -const deviceClientId = process.env.DEVICE_CLIENT_ID; -const deviceName = process.env.DEVICE_NAME; -const hostServiceSecret = process.env.HOST_SERVICE_SECRET; -const serviceVersion = process.env.HOST_SERVICE_VERSION ?? null; -const protocolVersion = HOST_SERVICE_PROTOCOL_VERSION; -const organizationId = process.env.ORGANIZATION_ID ?? ""; -const desktopVitePort = process.env.DESKTOP_VITE_PORT ?? "5173"; -const keepAliveAfterParent = process.env.KEEP_ALIVE_AFTER_PARENT === "1"; +async function main(): Promise { + const terminalBaseEnv = await resolveTerminalBaseEnv(); + initTerminalBaseEnv(terminalBaseEnv); -const auth = - authToken && cloudApiUrl ? new JwtApiAuthProvider(authToken) : undefined; -const hostAuth = hostServiceSecret - ? new PskHostAuthProvider(hostServiceSecret) - : undefined; + const authToken = process.env.AUTH_TOKEN; + const cloudApiUrl = process.env.CLOUD_API_URL; + const dbPath = process.env.HOST_DB_PATH; + const deviceClientId = process.env.DEVICE_CLIENT_ID; + const deviceName = process.env.DEVICE_NAME; + const hostServiceSecret = process.env.HOST_SERVICE_SECRET; + const serviceVersion = process.env.HOST_SERVICE_VERSION ?? null; + const protocolVersion = HOST_SERVICE_PROTOCOL_VERSION; + const organizationId = process.env.ORGANIZATION_ID ?? ""; + const desktopVitePort = process.env.DESKTOP_VITE_PORT ?? "5173"; + const keepAliveAfterParent = process.env.KEEP_ALIVE_AFTER_PARENT === "1"; -const { app, injectWebSocket } = createApp({ - credentials: new LocalGitCredentialProvider(), - auth, - hostAuth, - cloudApiUrl, - dbPath, - deviceClientId, - deviceName, - serviceVersion, - protocolVersion, - allowedOrigins: [ - `http://localhost:${desktopVitePort}`, - `http://127.0.0.1:${desktopVitePort}`, - ], -}); + const auth = + authToken && cloudApiUrl ? new JwtApiAuthProvider(authToken) : undefined; + const hostAuth = hostServiceSecret + ? new PskHostAuthProvider(hostServiceSecret) + : undefined; -const startedAt = Date.now(); + const { app, injectWebSocket } = createApp({ + credentials: new LocalGitCredentialProvider(), + auth, + hostAuth, + cloudApiUrl, + dbPath, + deviceClientId, + deviceName, + serviceVersion, + protocolVersion, + allowedOrigins: [ + `http://localhost:${desktopVitePort}`, + `http://127.0.0.1:${desktopVitePort}`, + ], + }); -const server = serve( - { fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, - (info: { port: number }) => { - if (organizationId) { - try { - writeManifest({ - pid: process.pid, - endpoint: `http://127.0.0.1:${info.port}`, - authToken: hostServiceSecret ?? "", - serviceVersion: serviceVersion ?? "", - protocolVersion: protocolVersion ?? 0, - startedAt, - organizationId, - }); - } catch (error) { - console.error("[host-service] Failed to write manifest:", error); + const startedAt = Date.now(); + const server = serve( + { fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, + (info: { port: number }) => { + if (organizationId) { + try { + writeManifest({ + pid: process.pid, + endpoint: `http://127.0.0.1:${info.port}`, + authToken: hostServiceSecret ?? "", + serviceVersion: serviceVersion ?? "", + protocolVersion: protocolVersion ?? 0, + startedAt, + organizationId, + }); + } catch (error) { + console.error("[host-service] Failed to write manifest:", error); + } } - } - process.send?.({ - type: "ready", - port: info.port, - serviceVersion, - protocolVersion, - startedAt, - }); - }, -); -injectWebSocket(server); + process.send?.({ + type: "ready", + port: info.port, + serviceVersion, + protocolVersion, + startedAt, + }); + }, + ); + injectWebSocket(server); -const shutdown = () => { - if (organizationId) { - removeManifest(organizationId); - } - server.close(); - process.exit(0); -}; + const shutdown = () => { + if (organizationId) { + removeManifest(organizationId); + } + server.close(); + process.exit(0); + }; -process.on("SIGTERM", shutdown); -process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); -// Orphan cleanup: exit if parent Electron process dies. -// Disabled in keep-alive mode so the service survives app quit. -if (!keepAliveAfterParent) { - const parentPid = process.ppid; - const parentCheck = setInterval(() => { - try { - process.kill(parentPid, 0); - } catch { - clearInterval(parentCheck); - console.log("[host-service] Parent process exited, shutting down"); - shutdown(); - } - }, 2000); - parentCheck.unref(); + if (!keepAliveAfterParent) { + const parentPid = process.ppid; + const parentCheck = setInterval(() => { + try { + process.kill(parentPid, 0); + } catch { + clearInterval(parentCheck); + console.log("[host-service] Parent process exited, shutting down"); + shutdown(); + } + }, 2000); + parentCheck.unref(); + } } + +void main().catch((error) => { + console.error("[host-service] Failed to start:", error); + process.exit(1); +}); diff --git a/apps/desktop/src/main/lib/host-service-manager.test.ts b/apps/desktop/src/main/lib/host-service-manager.test.ts index 5618eec4be5..89a3ddc74ee 100644 --- a/apps/desktop/src/main/lib/host-service-manager.test.ts +++ b/apps/desktop/src/main/lib/host-service-manager.test.ts @@ -31,7 +31,12 @@ class MockChildProcess extends EventEmitter { } const getProcessEnvWithShellPathMock = mock( - async (env: Record) => env, + async (baseEnv?: NodeJS.ProcessEnv): Promise> => ({ + ...(baseEnv ? (baseEnv as Record) : {}), + HOME: "/Users/test", + PATH: "/usr/bin:/bin", + SHELL: "/bin/zsh", + }), ); let lastChild: MockChildProcess | null = null; const spawnMock = mock((..._args: unknown[]) => { @@ -51,12 +56,9 @@ describe("HostServiceManager", () => { spyOn(childProcessModule, "spawn").mockImplementation(((..._args) => spawnMock(..._args)) as typeof childProcessModule.spawn); - spyOn(shellEnvModule, "getProcessEnvWithShellPath").mockImplementation((( - baseEnv: NodeJS.ProcessEnv = process.env, - ) => - getProcessEnvWithShellPathMock( - baseEnv as Record, - )) as typeof shellEnvModule.getProcessEnvWithShellPath); + spyOn(shellEnvModule, "getProcessEnvWithShellPath").mockImplementation( + (baseEnv) => getProcessEnvWithShellPathMock(baseEnv), + ); mock.module("electron", () => ({ app: { @@ -81,7 +83,12 @@ describe("HostServiceManager", () => { beforeEach(() => { getProcessEnvWithShellPathMock.mockReset(); getProcessEnvWithShellPathMock.mockImplementation( - async (env: Record) => env, + async (baseEnv?: NodeJS.ProcessEnv) => ({ + ...(baseEnv ? (baseEnv as Record) : {}), + HOME: "/Users/test", + PATH: "/usr/bin:/bin", + SHELL: "/bin/zsh", + }), ); spawnMock.mockReset(); spawnMock.mockImplementation(() => { @@ -91,7 +98,7 @@ describe("HostServiceManager", () => { lastChild = null; }); - it("dedupes concurrent starts while shell PATH is resolving", async () => { + it("dedupes concurrent starts while shell env is resolving", async () => { const manager = new HostServiceManager(); const pendingEnv = createDeferred>(); getProcessEnvWithShellPathMock.mockImplementation(() => pendingEnv.promise); @@ -101,11 +108,14 @@ describe("HostServiceManager", () => { expect(manager.getStatus("org-1")).toBe("starting"); - // Flush microtasks so tryAdopt completes (no manifest → falls through to spawn) await new Promise((resolve) => setTimeout(resolve, 0)); expect(getProcessEnvWithShellPathMock.mock.calls).toHaveLength(1); - pendingEnv.resolve({ PATH: "/usr/bin:/bin" }); + pendingEnv.resolve({ + HOME: "/Users/test", + PATH: "/usr/bin:/bin", + SHELL: "/bin/zsh", + }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(spawnMock.mock.calls).toHaveLength(1); @@ -121,6 +131,53 @@ describe("HostServiceManager", () => { expect(manager.getPort("org-1")).toBe(4242); }); + it("spawns host-service from shell-path env plus explicit service keys", async () => { + const manager = new HostServiceManager(); + manager.setAuthToken("auth-token"); + manager.setCloudApiUrl("https://api.example.com"); + + const originalValues = { + __HOST_SERVICE_RUNTIME_ENV_TEST__: + process.env.__HOST_SERVICE_RUNTIME_ENV_TEST__, + }; + process.env.__HOST_SERVICE_RUNTIME_ENV_TEST__ = "desktop-runtime"; + + try { + const startPromise = manager.start("org-1"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const spawnOptions = spawnMock.mock.calls[0]?.[2] as + | { env?: Record } + | undefined; + const env = spawnOptions?.env; + + expect(env).toBeDefined(); + expect(env).toMatchObject({ + HOME: "/Users/test", + PATH: "/usr/bin:/bin", + SHELL: "/bin/zsh", + __HOST_SERVICE_RUNTIME_ENV_TEST__: "desktop-runtime", + AUTH_TOKEN: "auth-token", + CLOUD_API_URL: "https://api.example.com", + ELECTRON_RUN_AS_NODE: "1", + SUPERSET_AGENT_HOOK_VERSION: expect.any(String), + SUPERSET_HOME_DIR: expect.any(String), + }); + expect(env?.HOST_SERVICE_SECRET).toEqual(expect.any(String)); + expect(env?.HOST_DB_PATH).toEqual(expect.any(String)); + + lastChild?.emit("message", { type: "ready", port: 4001 }); + await startPromise; + } finally { + if (originalValues.__HOST_SERVICE_RUNTIME_ENV_TEST__ !== undefined) { + process.env.__HOST_SERVICE_RUNTIME_ENV_TEST__ = + originalValues.__HOST_SERVICE_RUNTIME_ENV_TEST__; + } else { + delete process.env.__HOST_SERVICE_RUNTIME_ENV_TEST__; + } + } + }); + it("stopAll() kills all instances", async () => { const manager = new HostServiceManager(); diff --git a/apps/desktop/src/main/lib/host-service-manager.ts b/apps/desktop/src/main/lib/host-service-manager.ts index 75062557811..16b90543592 100644 --- a/apps/desktop/src/main/lib/host-service-manager.ts +++ b/apps/desktop/src/main/lib/host-service-manager.ts @@ -4,7 +4,9 @@ import { randomBytes } from "node:crypto"; import { EventEmitter } from "node:events"; import path from "node:path"; import { app } from "electron"; +import { env as sharedEnv } from "shared/env.shared"; import { getProcessEnvWithShellPath } from "../../lib/trpc/routers/workspaces/utils/shell-env"; +import { SUPERSET_HOME_DIR } from "./app-environment"; import { getDeviceName, getHashedDeviceId } from "./device-info"; import { HOST_SERVICE_PROTOCOL_VERSION, @@ -15,6 +17,7 @@ import { readManifest, removeManifest, } from "./host-service-manifest"; +import { HOOK_PROTOCOL_VERSION } from "./terminal/env"; export type HostServiceStatus = | "starting" @@ -123,8 +126,10 @@ async function buildHostServiceEnv( secret: string, ): Promise> { const orgDir = manifestDir(organizationId); + return getProcessEnvWithShellPath({ ...(process.env as Record), + // Host-service runtime keys ELECTRON_RUN_AS_NODE: "1", ORGANIZATION_ID: organizationId, DEVICE_CLIENT_ID: getHashedDeviceId(), @@ -137,6 +142,10 @@ async function buildHostServiceEnv( HOST_MIGRATIONS_PATH: app.isPackaged ? path.join(process.resourcesPath, "resources/host-migrations") : path.join(app.getAppPath(), "../../packages/host-service/drizzle"), + DESKTOP_VITE_PORT: String(sharedEnv.DESKTOP_VITE_PORT), + SUPERSET_HOME_DIR: SUPERSET_HOME_DIR, + SUPERSET_AGENT_HOOK_PORT: String(sharedEnv.DESKTOP_NOTIFICATIONS_PORT), + SUPERSET_AGENT_HOOK_VERSION: HOOK_PROTOCOL_VERSION, }); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 013800f9d6b..21d00258d4f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -10,6 +10,8 @@ import type { TerminalPaneData, } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; import { useWorkspaceWsUrl } from "renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; +import { useTheme } from "renderer/stores/theme"; +import { resolveTerminalThemeType } from "renderer/stores/theme/utils"; import { useTerminalAppearance } from "./hooks/useTerminalAppearance"; interface TerminalPaneProps { @@ -29,13 +31,23 @@ function getConnectionState(terminalId: string): ConnectionState { export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) { const { terminalId } = ctx.pane.data as TerminalPaneData; const containerRef = useRef(null); + const activeTheme = useTheme(); const appearance = useTerminalAppearance(); const appearanceRef = useRef(appearance); appearanceRef.current = appearance; + const initialThemeTypeRef = useRef< + ReturnType + >( + resolveTerminalThemeType({ + activeThemeType: activeTheme?.type, + }), + ); + const initialThemeType = initialThemeTypeRef.current; const websocketUrl = useWorkspaceWsUrl(`/terminal/${terminalId}`, { workspaceId, + themeType: initialThemeType, }); const connectionState = useSyncExternalStore( diff --git a/bun.lock b/bun.lock index 09d1effee45..ea349e5d027 100644 --- a/bun.lock +++ b/bun.lock @@ -108,10 +108,6 @@ "typescript": "^5.9.3", }, }, - "apps/cli": { - "name": "cli", - "version": "0.0.0", - }, "apps/desktop": { "name": "@superset/desktop", "version": "1.4.7", @@ -721,6 +717,7 @@ "@superset/typescript": "workspace:*", "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.9.1", + "bun-types": "^1.3.1", "drizzle-kit": "0.31.8", "typescript": "^5.9.3", }, @@ -3142,8 +3139,6 @@ "clean-yaml-object": ["clean-yaml-object@0.1.0", "", {}, "sha512-3yONmlN9CSAkzNwnRCiJQ7Q2xK5mWuEfL3PuTZcAUzhObbXsfsnMptJzXwz93nc5zn9V9TwCVMmV7w4xsm43dw=="], - "cli": ["cli@workspace:apps/cli"], - "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], diff --git a/packages/host-service/package.json b/packages/host-service/package.json index 87033f68b91..3d26d7ef096 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -23,6 +23,10 @@ "./trpc": { "types": "./src/trpc/index.ts", "default": "./src/trpc/index.ts" + }, + "./terminal-env": { + "types": "./src/terminal/env.ts", + "default": "./src/terminal/env.ts" } }, "scripts": { @@ -53,6 +57,7 @@ "@superset/typescript": "workspace:*", "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.9.1", + "bun-types": "^1.3.1", "drizzle-kit": "0.31.8", "typescript": "^5.9.3" } diff --git a/packages/host-service/src/serve.ts b/packages/host-service/src/serve.ts index 7f4c369e782..e9524b3739c 100644 --- a/packages/host-service/src/serve.ts +++ b/packages/host-service/src/serve.ts @@ -2,15 +2,26 @@ import { serve } from "@hono/node-server"; import { createApp } from "./app"; import { env } from "./env"; import { PskHostAuthProvider } from "./providers/host-auth"; +import { initTerminalBaseEnv, resolveTerminalBaseEnv } from "./terminal/env"; -const hostAuth = new PskHostAuthProvider(env.HOST_SERVICE_SECRET); -const { app, injectWebSocket } = createApp({ - dbPath: env.HOST_DB_PATH, - hostAuth, - allowedOrigins: env.CORS_ORIGINS ?? [], -}); +async function main(): Promise { + const terminalBaseEnv = await resolveTerminalBaseEnv(); + initTerminalBaseEnv(terminalBaseEnv); + + const hostAuth = new PskHostAuthProvider(env.HOST_SERVICE_SECRET); + const { app, injectWebSocket } = createApp({ + dbPath: env.HOST_DB_PATH, + hostAuth, + allowedOrigins: env.CORS_ORIGINS ?? [], + }); + + const server = serve({ fetch: app.fetch, port: env.PORT }, (info) => { + console.log(`[host-service] listening on http://localhost:${info.port}`); + }); + injectWebSocket(server); +} -const server = serve({ fetch: app.fetch, port: env.PORT }, (info) => { - console.log(`[host-service] listening on http://localhost:${info.port}`); +void main().catch((error) => { + console.error("[host-service] Failed to start:", error); + process.exit(1); }); -injectWebSocket(server); diff --git a/packages/host-service/src/terminal/clean-shell-env.ts b/packages/host-service/src/terminal/clean-shell-env.ts new file mode 100644 index 00000000000..96d331927ce --- /dev/null +++ b/packages/host-service/src/terminal/clean-shell-env.ts @@ -0,0 +1,185 @@ +import { type ChildProcess, spawn } from "node:child_process"; + +const SHELL_ENV_TIMEOUT_MS = 8_000; +const CACHE_TTL_MS = 60_000; +const DELIMITER = "__SUPERSET_SHELL_ENV__"; + +const SHELL_BOOTSTRAP_KEYS = [ + "HOME", + "USER", + "LOGNAME", + "SHELL", + "PATH", + "TERM", + "TMPDIR", + "LANG", + "LC_ALL", + "LC_CTYPE", + "__CF_USER_TEXT_ENCODING", + "Apple_PubSub_Socket_Render", + "COMSPEC", + "USERPROFILE", + "SYSTEMROOT", +]; + +const COMMON_MACOS_PATHS = [ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/local/sbin", +]; + +function augmentPathForMacOS( + env: Record, + platform: NodeJS.Platform = process.platform, +): void { + if (platform !== "darwin") return; + + const currentPath = env.PATH ?? ""; + const currentEntries = currentPath.split(":").filter(Boolean); + const pathEntries = new Set(currentEntries); + const missingPaths = COMMON_MACOS_PATHS.filter( + (path) => !pathEntries.has(path), + ); + env.PATH = [...missingPaths, currentPath].filter(Boolean).join(":"); +} + +function buildMinimalEnv(): Record { + const env: Record = { + DISABLE_AUTO_UPDATE: "true", + ZSH_TMUX_AUTOSTARTED: "true", + ZSH_TMUX_AUTOSTART: "false", + }; + + for (const key of SHELL_BOOTSTRAP_KEYS) { + const value = process.env[key]; + if (value) env[key] = value; + } + + augmentPathForMacOS(env); + return env; +} + +function resolveShellForEnv(): string { + if (process.platform === "win32") { + return process.env.COMSPEC || "cmd.exe"; + } + return process.env.SHELL || "/bin/sh"; +} + +function parseEnvOutput(stdout: string): Record { + const envSection = stdout.split(DELIMITER)[1]; + if (!envSection) { + throw new Error("Failed to parse shell env output - delimiter not found"); + } + + const result: Record = {}; + for (const line of envSection.split("\n").filter(Boolean)) { + const idx = line.indexOf("="); + if (idx > 0) { + result[line.slice(0, idx)] = line.slice(idx + 1); + } + } + + if (Object.keys(result).length === 0) { + throw new Error( + "Shell env resolution returned empty - shell may have failed to start", + ); + } + + return result; +} + +function spawnCleanShellEnv(): Promise> { + return new Promise((resolve, reject) => { + const shell = resolveShellForEnv(); + const env = buildMinimalEnv(); + const command = `echo -n "${DELIMITER}"; command env; echo -n "${DELIMITER}"; exit`; + + let child: ChildProcess; + try { + child = spawn(shell, ["-i", "-l", "-c", command], { + detached: true, + stdio: ["ignore", "pipe", "pipe"], + env, + }); + } catch (error) { + return reject( + new Error( + `Failed to spawn shell ${shell}: ${error instanceof Error ? error.message : error}`, + ), + ); + } + + const stdoutBuffers: Buffer[] = []; + const stderrBuffers: Buffer[] = []; + + child.stdout?.on("data", (data: Buffer) => stdoutBuffers.push(data)); + child.stderr?.on("data", (data: Buffer) => stderrBuffers.push(data)); + + const timeout = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + // Already exited. + } + + reject( + new Error( + `Shell env resolution timed out after ${SHELL_ENV_TIMEOUT_MS}ms`, + ), + ); + }, SHELL_ENV_TIMEOUT_MS); + + child.on("error", (error) => { + clearTimeout(timeout); + reject(new Error(`Shell process error for ${shell}: ${error.message}`)); + }); + + child.on("close", (code, signal) => { + clearTimeout(timeout); + + const stderr = Buffer.concat(stderrBuffers).toString("utf8").trim(); + if (stderr) { + console.debug("[terminal-clean-shell-env] stderr:", stderr); + } + + if (code !== 0 && code !== null) { + return reject( + new Error( + `Shell ${shell} exited with code ${code}${signal ? `, signal ${signal}` : ""}`, + ), + ); + } + + try { + resolve(parseEnvOutput(Buffer.concat(stdoutBuffers).toString("utf8"))); + } catch (error) { + reject(error); + } + }); + + child.unref(); + }); +} + +let cache: Record | null = null; +let cacheTime = 0; + +export async function getStrictShellEnvironment(): Promise< + Record +> { + if (cache && Date.now() - cacheTime < CACHE_TTL_MS) { + return { ...cache }; + } + + const env = await spawnCleanShellEnv(); + cache = env; + cacheTime = Date.now(); + return { ...cache }; +} + +export function clearStrictShellEnvCache(): void { + cache = null; + cacheTime = 0; +} diff --git a/packages/host-service/src/terminal/env-strip.ts b/packages/host-service/src/terminal/env-strip.ts new file mode 100644 index 00000000000..e5eab939cd4 --- /dev/null +++ b/packages/host-service/src/terminal/env-strip.ts @@ -0,0 +1,59 @@ +/** + * Runtime env stripping for v2 terminals. + * + * Denylist approach: the host-service base env is a shell-derived snapshot + * plus explicit runtime additions from desktop. We strip the known additions + * rather than allowlisting, because the shell snapshot should pass through + * untouched (version managers, proxy config, etc.). + */ + +/** + * Exact keys injected by desktop into host-service. + * + * DESKTOP_* and DEVICE_* are exact keys (not prefixes) because + * DESKTOP_SESSION, DESKTOP_STARTUP_ID etc. are legitimate Linux vars. + */ +const HOST_SERVICE_RUNTIME_KEYS = new Set([ + "AUTH_TOKEN", + "CLOUD_API_URL", + "DESKTOP_VITE_PORT", + "DEVICE_CLIENT_ID", + "DEVICE_NAME", + "KEEP_ALIVE_AFTER_PARENT", + "ORGANIZATION_ID", +]); + +const NODE_APP_KEYS = new Set(["NODE_ENV", "NODE_OPTIONS", "NODE_PATH"]); + +const STRIP_PREFIXES = [ + "npm_", + "npm_config_", + "ELECTRON_", + "VITE_", + "NEXT_PUBLIC_", + "TURBO_", + "HOST_", +]; + +const SUPERSET_KEEP_KEYS = new Set([ + "SUPERSET_HOME_DIR", + "SUPERSET_AGENT_HOOK_PORT", + "SUPERSET_AGENT_HOOK_VERSION", +]); + +export function stripTerminalRuntimeEnv( + baseEnv: Record, +): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(baseEnv)) { + if (HOST_SERVICE_RUNTIME_KEYS.has(key)) continue; + if (NODE_APP_KEYS.has(key)) continue; + if (STRIP_PREFIXES.some((prefix) => key.startsWith(prefix))) continue; + if (key.startsWith("SUPERSET_") && !SUPERSET_KEEP_KEYS.has(key)) continue; + + result[key] = value; + } + + return result; +} diff --git a/packages/host-service/src/terminal/env.test.ts b/packages/host-service/src/terminal/env.test.ts new file mode 100644 index 00000000000..d1375fdde0c --- /dev/null +++ b/packages/host-service/src/terminal/env.test.ts @@ -0,0 +1,508 @@ +import { describe, expect, test } from "bun:test"; +import { + buildV2TerminalEnv, + getShellBootstrapEnv, + getShellLaunchArgs, + getTerminalBaseEnv, + initTerminalBaseEnv, + normalizeUtf8Locale, + resetTerminalBaseEnvForTests, + resolveLaunchShell, + stripTerminalRuntimeEnv, +} from "./env"; + +// ── resolveLaunchShell ─────────────────────────────────────────────── + +describe("resolveLaunchShell", () => { + test("returns SHELL from base env on non-Windows", () => { + expect(resolveLaunchShell({ SHELL: "/usr/local/bin/fish" })).toBe( + "/usr/local/bin/fish", + ); + }); + + test("falls back to /bin/sh when SHELL is absent", () => { + expect(resolveLaunchShell({})).toBe("/bin/sh"); + }); + + test("does not default to /bin/zsh", () => { + expect(resolveLaunchShell({})).not.toBe("/bin/zsh"); + }); +}); + +// ── normalizeUtf8Locale ────────────────────────────────────────────── + +describe("normalizeUtf8Locale", () => { + test("LC_ALL takes precedence over LANG (POSIX)", () => { + expect( + normalizeUtf8Locale({ LC_ALL: "fr_FR.UTF-8", LANG: "en_US.UTF-8" }), + ).toBe("fr_FR.UTF-8"); + }); + + test("falls back to LANG when LC_ALL is absent", () => { + expect(normalizeUtf8Locale({ LANG: "ja_JP.UTF-8" })).toBe("ja_JP.UTF-8"); + }); + + test("matches case-insensitive utf8 variants", () => { + expect(normalizeUtf8Locale({ LANG: "en_US.utf8" })).toBe("en_US.utf8"); + expect(normalizeUtf8Locale({ LC_ALL: "C.UTF8" })).toBe("C.UTF8"); + }); + + test("defaults to en_US.UTF-8", () => { + expect(normalizeUtf8Locale({})).toBe("en_US.UTF-8"); + }); + + test("ignores non-UTF-8 locales", () => { + expect(normalizeUtf8Locale({ LANG: "C", LC_ALL: "POSIX" })).toBe( + "en_US.UTF-8", + ); + }); +}); + +// ── stripTerminalRuntimeEnv ────────────────────────────────────────── + +describe("stripTerminalRuntimeEnv", () => { + const secretsEnv: Record = { + // Host-service runtime keys that must not leak + AUTH_TOKEN: "secret-token", + HOST_SERVICE_SECRET: "secret", + ORGANIZATION_ID: "org-123", + DEVICE_CLIENT_ID: "device-abc", + DEVICE_NAME: "My Mac", + ELECTRON_RUN_AS_NODE: "1", + HOST_DB_PATH: "/tmp/host.db", + HOST_MANIFEST_DIR: "/tmp/manifests", + HOST_MIGRATIONS_PATH: "/tmp/migrations", + HOST_SERVICE_VERSION: "1.2.3", + KEEP_ALIVE_AFTER_PARENT: "1", + CLOUD_API_URL: "https://api.example.com", + DESKTOP_VITE_PORT: "5173", + // Node/app keys + NODE_ENV: "development", + NODE_OPTIONS: "--max-old-space-size=4096", + NODE_PATH: "/some/path", + // Dev-runner and Electron runtime vars + npm_package_name: "superset", + npm_config_registry: "https://registry.npmjs.org", + npm_lifecycle_event: "dev", + ELECTRON_ENABLE_LOGGING: "1", + // Build-tool prefix keys + VITE_API_URL: "http://localhost:3000", + NEXT_PUBLIC_KEY: "pk_123", + TURBO_TEAM: "my-team", + // Legacy SUPERSET_* vars that should be stripped + SUPERSET_PANE_ID: "pane-1", + SUPERSET_TAB_ID: "tab-1", + SUPERSET_PORT: "51741", + SUPERSET_HOOK_VERSION: "2", + SUPERSET_WORKSPACE_NAME: "my-ws", + // Keys that SHOULD survive + HOME: "/Users/test", + PATH: "/usr/bin:/usr/local/bin", + SHELL: "/bin/zsh", + EDITOR: "vim", + SUPERSET_HOME_DIR: "/Users/test/.superset", + SUPERSET_AGENT_HOOK_PORT: "51741", + SUPERSET_AGENT_HOOK_VERSION: "2", + }; + + test("app/runtime secrets do not reach PTY env", () => { + const result = stripTerminalRuntimeEnv(secretsEnv); + expect(result.AUTH_TOKEN).toBeUndefined(); + expect(result.HOST_SERVICE_SECRET).toBeUndefined(); + expect(result.ORGANIZATION_ID).toBeUndefined(); + expect(result.DEVICE_CLIENT_ID).toBeUndefined(); + expect(result.ELECTRON_RUN_AS_NODE).toBeUndefined(); + expect(result.HOST_DB_PATH).toBeUndefined(); + expect(result.CLOUD_API_URL).toBeUndefined(); + expect(result.DESKTOP_VITE_PORT).toBeUndefined(); + }); + + test("host-service control vars do not reach PTY env", () => { + const result = stripTerminalRuntimeEnv(secretsEnv); + expect(result.HOST_MANIFEST_DIR).toBeUndefined(); + expect(result.HOST_MIGRATIONS_PATH).toBeUndefined(); + expect(result.HOST_SERVICE_VERSION).toBeUndefined(); + expect(result.KEEP_ALIVE_AFTER_PARENT).toBeUndefined(); + expect(result.DEVICE_NAME).toBeUndefined(); + }); + + test("Node/app keys are stripped", () => { + const result = stripTerminalRuntimeEnv(secretsEnv); + expect(result.NODE_ENV).toBeUndefined(); + expect(result.NODE_OPTIONS).toBeUndefined(); + expect(result.NODE_PATH).toBeUndefined(); + }); + + test("dev-runner and Electron runtime vars do not reach PTY env", () => { + const result = stripTerminalRuntimeEnv(secretsEnv); + expect(result.npm_package_name).toBeUndefined(); + expect(result.npm_config_registry).toBeUndefined(); + expect(result.npm_lifecycle_event).toBeUndefined(); + expect(result.ELECTRON_ENABLE_LOGGING).toBeUndefined(); + }); + + test("HOST_* prefix is stripped, DESKTOP_*/DEVICE_* are exact-key only", () => { + const env: Record = { + // HOST_* prefix: all stripped + HOST_DB_PATH: "/tmp/db", + HOST_MANIFEST_DIR: "/tmp/manifests", + HOST_SERVICE_SECRET: "secret", + // DESKTOP_* / DEVICE_*: only our exact keys stripped + DESKTOP_VITE_PORT: "5173", + DEVICE_CLIENT_ID: "abc", + DEVICE_NAME: "Mac", + // Legitimate Linux desktop vars: must survive + DESKTOP_SESSION: "gnome", + DESKTOP_STARTUP_ID: "startup-123", + HOME: "/Users/test", + }; + const result = stripTerminalRuntimeEnv(env); + expect(result.HOST_DB_PATH).toBeUndefined(); + expect(result.HOST_MANIFEST_DIR).toBeUndefined(); + expect(result.HOST_SERVICE_SECRET).toBeUndefined(); + expect(result.DESKTOP_VITE_PORT).toBeUndefined(); + expect(result.DEVICE_CLIENT_ID).toBeUndefined(); + expect(result.DEVICE_NAME).toBeUndefined(); + // Linux desktop vars preserved + expect(result.DESKTOP_SESSION).toBe("gnome"); + expect(result.DESKTOP_STARTUP_ID).toBe("startup-123"); + expect(result.HOME).toBe("/Users/test"); + }); + + test("build-tool prefix keys are stripped", () => { + const result = stripTerminalRuntimeEnv(secretsEnv); + expect(result.VITE_API_URL).toBeUndefined(); + expect(result.NEXT_PUBLIC_KEY).toBeUndefined(); + expect(result.TURBO_TEAM).toBeUndefined(); + }); + + test("removed legacy vars do not reach PTY env", () => { + const result = stripTerminalRuntimeEnv(secretsEnv); + expect(result.SUPERSET_PANE_ID).toBeUndefined(); + expect(result.SUPERSET_TAB_ID).toBeUndefined(); + expect(result.SUPERSET_PORT).toBeUndefined(); + expect(result.SUPERSET_HOOK_VERSION).toBeUndefined(); + expect(result.SUPERSET_WORKSPACE_NAME).toBeUndefined(); + }); + + test("user shell env vars survive stripping", () => { + const result = stripTerminalRuntimeEnv(secretsEnv); + expect(result.HOME).toBe("/Users/test"); + expect(result.PATH).toBe("/usr/bin:/usr/local/bin"); + expect(result.SHELL).toBe("/bin/zsh"); + expect(result.EDITOR).toBe("vim"); + }); + + test("explicit Superset support keys are kept", () => { + const result = stripTerminalRuntimeEnv(secretsEnv); + expect(result.SUPERSET_HOME_DIR).toBe("/Users/test/.superset"); + expect(result.SUPERSET_AGENT_HOOK_PORT).toBe("51741"); + expect(result.SUPERSET_AGENT_HOOK_VERSION).toBe("2"); + }); + + test("shell-derived env preserves user tooling vars", () => { + const shellEnv: Record = { + HOME: "/Users/dev", + PATH: "/opt/homebrew/bin:/usr/local/bin:/usr/bin", + SHELL: "/bin/zsh", + NVM_DIR: "/Users/dev/.nvm", + PYENV_ROOT: "/Users/dev/.pyenv", + GOPATH: "/Users/dev/go", + SSH_AUTH_SOCK: "/tmp/ssh-agent.sock", + }; + const result = stripTerminalRuntimeEnv(shellEnv); + expect(result.NVM_DIR).toBe("/Users/dev/.nvm"); + expect(result.PYENV_ROOT).toBe("/Users/dev/.pyenv"); + expect(result.GOPATH).toBe("/Users/dev/go"); + expect(result.SSH_AUTH_SOCK).toBe("/tmp/ssh-agent.sock"); + }); +}); + +// ── Shell launch behavior ──────────────────────────────────────────── + +describe("getShellLaunchArgs", () => { + const supersetHomeDir = "/tmp/test-superset"; + + test("zsh launches as login shell", () => { + expect(getShellLaunchArgs({ shell: "/bin/zsh", supersetHomeDir })).toEqual([ + "-l", + ]); + }); + + test("bash falls back to login shell when rcfile missing", () => { + const args = getShellLaunchArgs({ shell: "/bin/bash", supersetHomeDir }); + expect(args).toEqual(["-l"]); + }); + + test("fish uses init-command", () => { + const args = getShellLaunchArgs({ + shell: "/usr/bin/fish", + supersetHomeDir, + }); + expect(args[0]).toBe("-l"); + expect(args[1]).toBe("--init-command"); + expect(args[2]).toContain("_superset_bin"); + expect(args[2]).toContain("superset-shell-ready"); + }); + + test("sh launches as login shell", () => { + expect(getShellLaunchArgs({ shell: "/bin/sh", supersetHomeDir })).toEqual([ + "-l", + ]); + }); + + test("ksh launches as login shell", () => { + expect( + getShellLaunchArgs({ shell: "/usr/bin/ksh", supersetHomeDir }), + ).toEqual(["-l"]); + }); + + test("unsupported shells launch natively without bootstrap", () => { + expect( + getShellLaunchArgs({ shell: "/usr/bin/pwsh", supersetHomeDir }), + ).toEqual([]); + }); +}); + +describe("getShellBootstrapEnv", () => { + test("zsh bootstrap applies only when wrapper files exist", () => { + const result = getShellBootstrapEnv({ + shell: "/bin/zsh", + baseEnv: { HOME: "/Users/test" }, + supersetHomeDir: "/tmp/nonexistent-superset-dir", + }); + expect(result).toEqual({}); + }); + + test("bash returns no bootstrap env keys", () => { + const result = getShellBootstrapEnv({ + shell: "/bin/bash", + baseEnv: {}, + supersetHomeDir: "/tmp/test", + }); + expect(result).toEqual({}); + }); + + test("fish returns no bootstrap env keys", () => { + const result = getShellBootstrapEnv({ + shell: "/usr/bin/fish", + baseEnv: {}, + supersetHomeDir: "/tmp/test", + }); + expect(result).toEqual({}); + }); + + test("unsupported shells return no bootstrap env", () => { + const result = getShellBootstrapEnv({ + shell: "/usr/bin/pwsh", + baseEnv: {}, + supersetHomeDir: "/tmp/test", + }); + expect(result).toEqual({}); + }); +}); + +// ── Terminal base env preservation ─────────────────────────────────── + +describe("terminal base env preservation", () => { + test("getTerminalBaseEnv throws when not initialized", () => { + resetTerminalBaseEnvForTests(); + expect(() => getTerminalBaseEnv()).toThrow("not initialized"); + }); + + test("PTY env is built from preserved snapshot, not live process.env", () => { + resetTerminalBaseEnvForTests(); + + // Simulate host-service startup: process.env = shellSnapshot + runtime keys + const originalProcessEnv = { ...process.env }; + try { + // Set up process.env as if desktop spawned host-service + process.env.HOME = "/Users/test"; + process.env.PATH = "/usr/bin"; + process.env.SHELL = "/bin/zsh"; + process.env.NVM_DIR = "/Users/test/.nvm"; + // Runtime keys that should be stripped + process.env.HOST_SERVICE_SECRET = "secret-123"; + process.env.ORGANIZATION_ID = "org-abc"; + process.env.ELECTRON_RUN_AS_NODE = "1"; + + initTerminalBaseEnv(); + + const baseEnv = getTerminalBaseEnv(); + + // Shell vars preserved + expect(baseEnv.HOME).toBe("/Users/test"); + expect(baseEnv.PATH).toBe("/usr/bin"); + expect(baseEnv.SHELL).toBe("/bin/zsh"); + expect(baseEnv.NVM_DIR).toBe("/Users/test/.nvm"); + + // Runtime keys stripped + expect(baseEnv.HOST_SERVICE_SECRET).toBeUndefined(); + expect(baseEnv.ORGANIZATION_ID).toBeUndefined(); + expect(baseEnv.ELECTRON_RUN_AS_NODE).toBeUndefined(); + + // Modify process.env after init — preserved snapshot unaffected + process.env.INJECTED_LATER = "should-not-appear"; + const freshBaseEnv = getTerminalBaseEnv(); + expect(freshBaseEnv.INJECTED_LATER).toBeUndefined(); + } finally { + // Restore original process.env + for (const key of Object.keys(process.env)) { + if (!(key in originalProcessEnv)) { + delete process.env[key]; + } + } + for (const [key, value] of Object.entries(originalProcessEnv)) { + process.env[key] = value; + } + resetTerminalBaseEnvForTests(); + } + }); + + test("shell resolution failure means no terminal base env", () => { + resetTerminalBaseEnvForTests(); + // Without calling initTerminalBaseEnv(), getTerminalBaseEnv throws + expect(() => getTerminalBaseEnv()).toThrow(); + }); +}); + +// ── buildV2TerminalEnv ─────────────────────────────────────────────── + +describe("buildV2TerminalEnv", () => { + const baseParams = { + baseEnv: { + HOME: "/Users/test", + PATH: "/usr/bin", + SHELL: "/bin/zsh", + SUPERSET_HOME_DIR: "/Users/test/.superset", + }, + shell: "/bin/zsh", + supersetHomeDir: "/Users/test/.superset", + cwd: "/tmp/workspace", + terminalId: "term-1", + workspaceId: "ws-1", + workspacePath: "/tmp/workspace", + rootPath: "/tmp/repo", + hostServiceVersion: "2.0.0", + supersetEnv: "production" as const, + agentHookPort: "51741", + agentHookVersion: "2", + }; + + test("injects the public terminal contract and retained v2 metadata", () => { + const env = buildV2TerminalEnv(baseParams); + expect(env).toMatchObject({ + TERM: "xterm-256color", + TERM_PROGRAM: "Superset", + TERM_PROGRAM_VERSION: "2.0.0", + COLORTERM: "truecolor", + PWD: "/tmp/workspace", + SUPERSET_TERMINAL_ID: "term-1", + SUPERSET_WORKSPACE_ID: "ws-1", + SUPERSET_WORKSPACE_PATH: "/tmp/workspace", + SUPERSET_ROOT_PATH: "/tmp/repo", + SUPERSET_ENV: "production", + SUPERSET_AGENT_HOOK_PORT: "51741", + SUPERSET_AGENT_HOOK_VERSION: "2", + }); + expect(env.TERM_PROGRAM).toBe("Superset"); + expect(env.LANG).toContain("UTF-8"); + }); + + test("allows empty root path and alternate Superset env without breaking the contract", () => { + const env = buildV2TerminalEnv({ ...baseParams, rootPath: "" }); + expect(env.SUPERSET_ROOT_PATH).toBe(""); + + const devEnv = buildV2TerminalEnv({ + ...baseParams, + rootPath: "", + supersetEnv: "development", + }); + expect(devEnv.SUPERSET_ENV).toBe("development"); + expect(devEnv.SUPERSET_ROOT_PATH).toBe(""); + }); + + test("defaults COLORFGBG to dark mode", () => { + const env = buildV2TerminalEnv(baseParams); + expect(env.COLORFGBG).toBe("15;0"); + }); + + test("sets COLORFGBG to light mode when themeType is light", () => { + const env = buildV2TerminalEnv({ + ...baseParams, + themeType: "light", + }); + expect(env.COLORFGBG).toBe("0;15"); + }); + + test("drops removed v1 metadata while preserving user shell vars", () => { + const env = buildV2TerminalEnv({ + ...baseParams, + baseEnv: { + ...baseParams.baseEnv, + SUPERSET_PANE_ID: "pane-1", + SUPERSET_TAB_ID: "tab-1", + SUPERSET_PORT: "51741", + SUPERSET_HOOK_VERSION: "2", + SUPERSET_WORKSPACE_NAME: "my-workspace", + NVM_DIR: "/Users/test/.nvm", + SSH_AUTH_SOCK: "/tmp/ssh.sock", + }, + }); + expect(env.SUPERSET_PANE_ID).toBeUndefined(); + expect(env.SUPERSET_TAB_ID).toBeUndefined(); + expect(env.SUPERSET_PORT).toBeUndefined(); + expect(env.SUPERSET_HOOK_VERSION).toBeUndefined(); + expect(env.SUPERSET_WORKSPACE_NAME).toBeUndefined(); + expect(env.NVM_DIR).toBe("/Users/test/.nvm"); + expect(env.SSH_AUTH_SOCK).toBe("/tmp/ssh.sock"); + }); +}); + +// ── Integration: env never degenerates to process.env ──────────────── + +describe("v2 env contract boundary", () => { + test("runtime secrets in base env are stripped even when present", () => { + // Simulate a base env that somehow has runtime secrets + // (e.g. from shell snapshot contamination) + const env = buildV2TerminalEnv({ + baseEnv: { + HOME: "/Users/test", + PATH: "/usr/bin", + SHELL: "/bin/zsh", + HOST_SERVICE_SECRET: "top-secret", + AUTH_TOKEN: "bearer-xyz", + ORGANIZATION_ID: "org-abc", + NODE_ENV: "production", + VITE_SECRET: "vite-key", + npm_package_name: "superset", + ELECTRON_IS_DEV: "1", + }, + shell: "/bin/zsh", + supersetHomeDir: "/Users/test/.superset", + cwd: "/tmp/ws", + terminalId: "t-1", + workspaceId: "w-1", + workspacePath: "/tmp/ws", + rootPath: "", + hostServiceVersion: "2.0.0", + supersetEnv: "production", + agentHookPort: "51741", + agentHookVersion: "2", + }); + + // None of the runtime secrets should be present + expect(env.HOST_SERVICE_SECRET).toBeUndefined(); + expect(env.AUTH_TOKEN).toBeUndefined(); + expect(env.ORGANIZATION_ID).toBeUndefined(); + expect(env.NODE_ENV).toBeUndefined(); + expect(env.VITE_SECRET).toBeUndefined(); + expect(env.npm_package_name).toBeUndefined(); + expect(env.ELECTRON_IS_DEV).toBeUndefined(); + + // But user shell vars remain + expect(env.HOME).toBe("/Users/test"); + expect(env.PATH).toBe("/usr/bin"); + expect(env.SHELL).toBe("/bin/zsh"); + }); +}); diff --git a/packages/host-service/src/terminal/env.ts b/packages/host-service/src/terminal/env.ts new file mode 100644 index 00000000000..135594ba068 --- /dev/null +++ b/packages/host-service/src/terminal/env.ts @@ -0,0 +1,177 @@ +/** + * V2 terminal environment contract. + * + * PTY env is built from a preserved shell snapshot resolved by the host-service + * at startup — never from desktop main or the live host-service process.env. + */ + +export { stripTerminalRuntimeEnv } from "./env-strip"; +export type { ShellBootstrapParams, ShellLaunchParams } from "./shell-launch"; +export { + getShellBootstrapEnv, + getShellLaunchArgs, + getSupersetShellPaths, + resolveLaunchShell, +} from "./shell-launch"; + +import fs from "node:fs"; +import os from "node:os"; +import { + clearStrictShellEnvCache, + getStrictShellEnvironment, +} from "./clean-shell-env"; +import { stripTerminalRuntimeEnv } from "./env-strip"; +import { getShellBootstrapEnv } from "./shell-launch"; + +const MACOS_SYSTEM_CERT_FILE = "/etc/ssl/cert.pem"; +let cachedMacosSystemCertAvailable: boolean | null = null; + +function hasMacosSystemCertBundle(): boolean { + if (cachedMacosSystemCertAvailable !== null) { + return cachedMacosSystemCertAvailable; + } + cachedMacosSystemCertAvailable = fs.existsSync(MACOS_SYSTEM_CERT_FILE); + return cachedMacosSystemCertAvailable; +} + +// ── Shell snapshot preservation ────────────────────────────────────── + +let _terminalBaseEnv: Record | null = null; + +function snapshotStringEnv( + baseEnv: NodeJS.ProcessEnv | Record = process.env, +): Record { + const snapshot: Record = {}; + for (const [key, value] of Object.entries(baseEnv)) { + if (typeof value === "string") { + snapshot[key] = value; + } + } + return snapshot; +} + +/** + * Resolve the shell-derived terminal base env inside the host-service process. + * Desktop main should not construct or own this snapshot. + */ +export async function resolveTerminalBaseEnv(): Promise< + Record +> { + return getStrictShellEnvironment(); +} + +/** + * Capture the terminal base env at host-service startup. + * + * Accepts an explicit shell snapshot for the real startup path, but retains a + * process.env fallback for tests and local helpers. + */ +export function initTerminalBaseEnv(baseEnv?: Record): void { + _terminalBaseEnv = stripTerminalRuntimeEnv(snapshotStringEnv(baseEnv)); +} + +export function getTerminalBaseEnv(): Record { + if (!_terminalBaseEnv) { + throw new Error( + "Terminal base env not initialized. Call initTerminalBaseEnv() at host-service startup.", + ); + } + return { ..._terminalBaseEnv }; +} + +export function resetTerminalBaseEnvForTests(): void { + _terminalBaseEnv = null; + cachedMacosSystemCertAvailable = null; + clearStrictShellEnvCache(); +} + +// ── Locale ─────────────────────────────────────────────────────────── + +const UTF8_RE = /utf-?8/i; + +/** POSIX precedence: LC_ALL overrides LANG. Matches utf8/UTF-8/UTF8. */ +export function normalizeUtf8Locale(baseEnv: Record): string { + if (baseEnv.LC_ALL && UTF8_RE.test(baseEnv.LC_ALL)) return baseEnv.LC_ALL; + if (baseEnv.LANG && UTF8_RE.test(baseEnv.LANG)) return baseEnv.LANG; + return "en_US.UTF-8"; +} + +// ── V2 terminal env construction ───────────────────────────────────── + +interface BuildV2TerminalEnvParams { + baseEnv: Record; + shell: string; + supersetHomeDir: string; + themeType?: "dark" | "light"; + cwd: string; + terminalId: string; + workspaceId: string; + workspacePath: string; + rootPath: string; + hostServiceVersion: string; + supersetEnv: "development" | "production"; + agentHookPort: string; + agentHookVersion: string; +} + +/** + * Build the final v2 PTY environment. + * baseEnv must be the preserved shell snapshot from getTerminalBaseEnv(). + */ +export function buildV2TerminalEnv( + params: BuildV2TerminalEnvParams, +): Record { + const { + baseEnv, + shell, + supersetHomeDir, + themeType, + cwd, + terminalId, + workspaceId, + workspacePath, + rootPath, + hostServiceVersion, + supersetEnv, + agentHookPort, + agentHookVersion, + } = params; + + // Defense in depth — baseEnv is pre-stripped at init, but strip again + // to guarantee no runtime keys reach PTYs regardless of call site + const env = stripTerminalRuntimeEnv(baseEnv); + + Object.assign(env, getShellBootstrapEnv({ shell, baseEnv, supersetHomeDir })); + + env.TERM = "xterm-256color"; + env.TERM_PROGRAM = "Superset"; + env.TERM_PROGRAM_VERSION = hostServiceVersion; + env.COLORTERM = "truecolor"; + env.COLORFGBG = themeType === "light" ? "0;15" : "15;0"; + env.LANG = normalizeUtf8Locale(baseEnv); + env.PWD = cwd; + + env.SUPERSET_TERMINAL_ID = terminalId; + env.SUPERSET_WORKSPACE_ID = workspaceId; + env.SUPERSET_WORKSPACE_PATH = workspacePath; + env.SUPERSET_ROOT_PATH = rootPath; + env.SUPERSET_ENV = supersetEnv; + env.SUPERSET_AGENT_HOOK_PORT = agentHookPort; + env.SUPERSET_AGENT_HOOK_VERSION = agentHookVersion; + + if (supersetHomeDir) { + env.SUPERSET_HOME_DIR = supersetHomeDir; + } + + // Electron child processes can't access macOS Keychain for TLS cert verification, + // causing "x509: OSStatus -26276" in Go binaries like `gh`. File-based fallback. + if ( + os.platform() === "darwin" && + !env.SSL_CERT_FILE && + hasMacosSystemCertBundle() + ) { + env.SSL_CERT_FILE = MACOS_SYSTEM_CERT_FILE; + } + + return env; +} diff --git a/packages/host-service/src/terminal/shell-launch.ts b/packages/host-service/src/terminal/shell-launch.ts new file mode 100644 index 00000000000..950ee0cd256 --- /dev/null +++ b/packages/host-service/src/terminal/shell-launch.ts @@ -0,0 +1,116 @@ +/** + * Shell launch configuration for v2 terminals. + * + * Behavioral reference: apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts + * + * Upstream patterns: + * - VS Code: ZDOTDIR for zsh, --init-file for bash, --init-command for fish + * - Kitty: KITTY_ORIG_ZDOTDIR for zsh, ENV for bash, XDG_DATA_DIRS for fish + */ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; + +/** Does not default to /bin/zsh — falls back to /bin/sh (POSIX-guaranteed). */ +export function resolveLaunchShell(baseEnv: Record): string { + if (process.platform === "win32") { + return baseEnv.COMSPEC || "cmd.exe"; + } + return baseEnv.SHELL || "/bin/sh"; +} + +export function getSupersetShellPaths(supersetHomeDir: string): { + BIN_DIR: string; + ZSH_DIR: string; + BASH_DIR: string; +} { + return { + BIN_DIR: path.join(supersetHomeDir, "bin"), + ZSH_DIR: path.join(supersetHomeDir, "zsh"), + BASH_DIR: path.join(supersetHomeDir, "bash"), + }; +} + +function getShellName(shell: string): string { + return path.basename(shell); +} + +/** Matches desktop shell-wrappers.ts fish init: idempotent PATH prepend + shell-ready OSC marker. */ +function buildFishInitCommand(binDir: string): string { + const escaped = binDir + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("$", "\\$"); + return [ + `set -l _superset_bin "${escaped}"`, + `contains -- "$_superset_bin" $PATH`, + `or set -gx PATH "$_superset_bin" $PATH`, + `function _superset_shell_ready --on-event fish_prompt`, + `printf '\\033]777;superset-shell-ready\\007'`, + `functions -e _superset_shell_ready`, + `end`, + ].join("; "); +} + +export interface ShellBootstrapParams { + shell: string; + baseEnv: Record; + supersetHomeDir: string; +} + +/** + * Private bootstrap env for shell startup redirection. + * Only zsh needs env vars (ZDOTDIR). Bash/fish use args only. + */ +export function getShellBootstrapEnv( + params: ShellBootstrapParams, +): Record { + const { shell, baseEnv, supersetHomeDir } = params; + const shellName = getShellName(shell); + const paths = getSupersetShellPaths(supersetHomeDir); + + if (shellName === "zsh") { + const zshrc = path.join(paths.ZSH_DIR, ".zshrc"); + if (existsSync(zshrc)) { + return { + SUPERSET_ORIG_ZDOTDIR: baseEnv.ZDOTDIR || baseEnv.HOME || homedir(), + ZDOTDIR: paths.ZSH_DIR, + }; + } + } + + return {}; +} + +export interface ShellLaunchParams { + shell: string; + supersetHomeDir: string; +} + +export function getShellLaunchArgs(params: ShellLaunchParams): string[] { + const { shell, supersetHomeDir } = params; + const shellName = getShellName(shell); + const paths = getSupersetShellPaths(supersetHomeDir); + + if (shellName === "zsh") { + return ["-l"]; + } + + if (shellName === "bash") { + const rcfile = path.join(paths.BASH_DIR, "rcfile"); + if (existsSync(rcfile)) { + return ["--rcfile", rcfile]; + } + return ["-l"]; + } + + if (shellName === "fish") { + return ["-l", "--init-command", buildFishInitCommand(paths.BIN_DIR)]; + } + + if (shellName === "sh" || shellName === "ksh") { + return ["-l"]; + } + + return []; +} diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index bb52ab16c12..602186260a8 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -1,11 +1,16 @@ import { existsSync } from "node:fs"; -import { homedir } from "node:os"; import type { NodeWebSocket } from "@hono/node-ws"; import { eq } from "drizzle-orm"; import type { Hono } from "hono"; import { type IPty, spawn } from "node-pty"; import type { HostDb } from "../db"; -import { terminalSessions, workspaces } from "../db/schema"; +import { projects, terminalSessions, workspaces } from "../db/schema"; +import { + buildV2TerminalEnv, + getShellLaunchArgs, + getTerminalBaseEnv, + resolveLaunchShell, +} from "./env"; interface RegisterWorkspaceTerminalRouteOptions { app: Hono; @@ -13,6 +18,12 @@ interface RegisterWorkspaceTerminalRouteOptions { upgradeWebSocket: NodeWebSocket["upgradeWebSocket"]; } +function parseThemeType( + value: string | null | undefined, +): "dark" | "light" | undefined { + return value === "dark" || value === "light" ? value : undefined; +} + type TerminalClientMessage = | { type: "input"; data: string } | { type: "resize"; cols: number; rows: number } @@ -52,13 +63,6 @@ function sendMessage( socket.send(JSON.stringify(message)); } -function resolveShell(): string { - if (process.platform === "win32") { - return process.env.COMSPEC || "cmd.exe"; - } - return process.env.SHELL || "/bin/zsh"; -} - function bufferOutput(session: TerminalSession, data: string) { session.buffer.push(data); session.bufferBytes += data.length; @@ -102,12 +106,14 @@ function disposeSession(terminalId: string, db: HostDb) { interface CreateTerminalSessionOptions { terminalId: string; workspaceId: string; + themeType?: "dark" | "light"; db: HostDb; } function createTerminalSessionInternal({ terminalId, workspaceId, + themeType, db, }: CreateTerminalSessionOptions): TerminalSession | { error: string } { const existing = sessions.get(terminalId); @@ -123,22 +129,47 @@ function createTerminalSessionInternal({ return { error: "Workspace worktree not found" }; } + // Derive root path from the workspace's project + let rootPath = ""; + const project = db.query.projects + .findFirst({ where: eq(projects.id, workspace.projectId) }) + .sync(); + if (project?.repoPath) { + rootPath = project.repoPath; + } + const cwd = workspace.worktreePath; + // Use the preserved shell snapshot — never live process.env + const baseEnv = getTerminalBaseEnv(); + const supersetHomeDir = process.env.SUPERSET_HOME_DIR || ""; + const shell = resolveLaunchShell(baseEnv); + const shellArgs = getShellLaunchArgs({ shell, supersetHomeDir }); + const ptyEnv = buildV2TerminalEnv({ + baseEnv, + shell, + supersetHomeDir, + themeType, + cwd, + terminalId, + workspaceId, + workspacePath: workspace.worktreePath, + rootPath, + hostServiceVersion: process.env.HOST_SERVICE_VERSION || "unknown", + supersetEnv: + process.env.NODE_ENV === "development" ? "development" : "production", + agentHookPort: process.env.SUPERSET_AGENT_HOOK_PORT || "", + agentHookVersion: process.env.SUPERSET_AGENT_HOOK_VERSION || "", + }); + let pty: IPty; try { - pty = spawn(resolveShell(), [], { + pty = spawn(shell, shellArgs, { name: "xterm-256color", cwd, cols: 120, rows: 32, - env: { - ...process.env, - TERM: "xterm-256color", - COLORTERM: "truecolor", - HOME: process.env.HOME || homedir(), - PWD: cwd, - }, + env: ptyEnv, }); } catch (error) { return { @@ -210,6 +241,7 @@ export function registerWorkspaceTerminalRoute({ const body = await c.req.json<{ terminalId: string; workspaceId: string; + themeType?: string; }>(); if (!body.terminalId || !body.workspaceId) { @@ -219,6 +251,7 @@ export function registerWorkspaceTerminalRoute({ const result = createTerminalSessionInternal({ terminalId: body.terminalId, workspaceId: body.workspaceId, + themeType: parseThemeType(body.themeType), db, }); @@ -261,6 +294,7 @@ export function registerWorkspaceTerminalRoute({ upgradeWebSocket((c) => { const terminalId = c.req.param("terminalId") ?? ""; const workspaceId = c.req.query("workspaceId") ?? null; + const themeType = parseThemeType(c.req.query("themeType")); return { onOpen: (_event, ws) => { @@ -304,6 +338,7 @@ export function registerWorkspaceTerminalRoute({ const result = createTerminalSessionInternal({ terminalId, workspaceId, + themeType, db, }); diff --git a/packages/host-service/tsconfig.json b/packages/host-service/tsconfig.json index d6b95b87a3e..1e480221828 100644 --- a/packages/host-service/tsconfig.json +++ b/packages/host-service/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "@superset/typescript/base.json", "compilerOptions": { - "jsx": "react-jsx" + "jsx": "react-jsx", + "types": ["bun-types"] }, "include": ["src"] } diff --git a/plans/v2-terminal-env-handoff.md b/plans/v2-terminal-env-handoff.md new file mode 100644 index 00000000000..500a1679c59 --- /dev/null +++ b/plans/v2-terminal-env-handoff.md @@ -0,0 +1,674 @@ +# V2 Terminal Env Handoff + +Last refined: 2026-04-05 + +## Goal + +Define and implement a v2 terminal env contract that: + +- matches common terminal patterns from GitHub sources +- preserves user-needed shell env for normal shell behavior +- includes explicit shell integration behavior for common shells +- uses only a shell-derived base env for PTYs +- avoids leaking desktop, Electron, and host-service runtime env into PTYs +- keeps the useful parts of the v1 Superset notification contract, but renames + the v2-specific keys to make the contract clearer + +This doc is meant to be handed to another agent to implement directly. + +## Current state + +Current checked-out v2 terminal flow: + +- renderer opens `/terminal/${terminalId}?workspaceId=${workspaceId}` +- host-service spawns a fresh PTY per websocket-backed session +- host-service resolves the shell from inherited process env +- host-service currently spreads raw `process.env` into the PTY + +Relevant code: + +- `apps/desktop/src/main/lib/host-service-manager.ts` +- `apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts` +- `packages/host-service/src/terminal/terminal.ts` +- `apps/desktop/src/main/lib/terminal/env.ts` for the existing v1 contract + +Current PTY env in `packages/host-service/src/terminal/terminal.ts`: + +```ts +{ + ...process.env, + TERM: "xterm-256color", + COLORTERM: "truecolor", + HOME: process.env.HOME || homedir(), + PWD: workspace.worktreePath, +} +``` + +This is too loose in two places: + +1. host-service itself is spawned from desktop with an env built from desktop + `process.env` +2. PTYs then inherit host-service `process.env` wholesale + +That leaks whatever happens to be in the desktop and host-service runtime env +and does not define a stable contract for terminals. + +## Upstream patterns to follow + +GitHub sources: + +- VS Code terminal env injection: + https://github.com/microsoft/vscode/blob/main/src/vs/platform/terminal/node/terminalEnvironment.ts +- VS Code process env sanitization: + https://github.com/microsoft/vscode/blob/main/src/vs/base/common/processes.ts +- kitty shell integration: + https://github.com/kovidgoyal/kitty/blob/master/docs/shell-integration.rst +- WezTerm `TERM` docs: + https://github.com/wezterm/wezterm/blob/main/docs/config/lua/config/term.md +- WezTerm shell integration: + https://github.com/wezterm/wezterm/blob/main/docs/shell-integration.md +- Windows Terminal FAQ: + https://github.com/microsoft/terminal/wiki/Frequently-Asked-Questions-%28FAQ%29 + +What these tools converge on: + +- keep the public env surface small +- use shell-specific bootstrap vars only when loading shell integration +- sanitize app/runtime env before child processes and terminals instead of + inheriting it wholesale +- do not rely on env vars for dynamic session state +- keep `TERM` conservative unless terminfo is actually shipped +- do not treat env vars as the only reliable terminal identity signal + +Concrete VS Code pattern to follow: + +- VS Code uses a small set of private bootstrap vars for shell integration such + as `VSCODE_INJECTION`, `VSCODE_SHELL_ENV_REPORTING`, `VSCODE_PATH_PREFIX`, + `ZDOTDIR`, and `USER_ZDOTDIR` +- VS Code also sanitizes process env before crossing process boundaries by + stripping Electron and VS Code runtime keys like `ELECTRON_*` and most + `VSCODE_*` + +Superset v2 should follow the same shape: + +- shell-derived env is the base +- Superset adds a small explicit public contract +- Superset strips its own runtime env before PTY launch instead of inheriting it + by default + +## Refined v2 contract + +### 1. Env boundary + +The shell-derived env snapshot is the only valid PTY base env. + +For v2: + +- desktop should spawn host-service with the runtime env it needs +- host-service should resolve a shell-derived env snapshot for terminal use +- host-service should preserve that shell snapshot as a dedicated terminal + base env, separate from its own runtime `process.env` +- PTYs should be built from that dedicated shell snapshot plus explicit v2 + terminal vars + +Desktop `process.env` is not a valid PTY env source. + +Host-service `process.env` is not a valid PTY env source. + +Host-service runtime vars may exist in the host-service process env for the +service itself, but they are not part of the PTY base env and must never be +passed through to user terminals by default. + +### 2. Shell-derived base env + +Use a clean-shell resolver colocated with the host-service terminal code. + +But tighten the semantics: + +- normal path: use the resolved shell snapshot from a clean spawn +- failure path: fail closed for terminal env construction + +Important: + +- the existing `getShellEnvironment()` helper spawns a subshell that inherits + the full Electron `process.env`, which in dev includes all Vite `.env` + secrets — that contaminates the snapshot at the source +- the existing helper also falls back to `process.env` when shell env + resolution fails +- neither the contaminated snapshot nor the fallback are acceptable for v2 + terminal env construction + +For v2, shell snapshot resolution must: + +- spawn the user's login shell with a **minimal parent env** (HOME, USER, + SHELL, PATH, TERM, and a few OS-specific keys) so that Vite `.env` secrets + never enter the subshell +- let the shell's profile scripts populate the env with the user's actual + vars (version managers, proxy config, SSH agent, etc.) +- throw on failure instead of falling back to `process.env` + +This "clean spawn" approach means dev and production behave identically — the +snapshot only contains what the user's shell profile produces, never what +Electron or Vite loaded into the app process. + +For v2, PTY creation must never degenerate into `...process.env` or any other +desktop-runtime fallback. + +### 3. Public terminal env + +Inject this stable terminal surface by default: + +```sh +TERM=xterm-256color +TERM_PROGRAM=Superset +TERM_PROGRAM_VERSION= +COLORTERM=truecolor +LANG= +PWD= +``` + +Notes: + +- keep `TERM=xterm-256color` unless Superset ships and maintains terminfo +- `TERM_PROGRAM_VERSION` should come from the app/host-service version, not + `npm_package_version` +- `PWD` should reflect the resolved launch cwd +- for the current v2 path, launch cwd is the workspace worktree path +- `HOME`, `PATH`, `SHELL`, proxy vars, SSH agent vars, and version-manager vars + should come from the shell-derived base env rather than being redefined as + part of the public contract + +### 4. Superset-specific metadata retained in v2 + +We do want to keep a trimmed, explicit Superset contract for v2 notification +and integration flows. + +Keep these explicit vars in v2: + +```sh +SUPERSET_TERMINAL_ID= +SUPERSET_WORKSPACE_ID= +SUPERSET_WORKSPACE_PATH= +SUPERSET_ROOT_PATH= +SUPERSET_ENV= +SUPERSET_AGENT_HOOK_PORT= +SUPERSET_AGENT_HOOK_VERSION= +``` + +Rename the old v1 vars as follows: + +- `SUPERSET_PANE_ID` -> `SUPERSET_TERMINAL_ID` +- `SUPERSET_PORT` -> `SUPERSET_AGENT_HOOK_PORT` +- `SUPERSET_HOOK_VERSION` -> `SUPERSET_AGENT_HOOK_VERSION` + +Drop this key entirely in v2: + +- `SUPERSET_TAB_ID` + +Do not use a blanket `SUPERSET_*` passthrough rule in v2. + +The v2 Superset metadata surface should stay explicit and minimal. + +### 5. Shell behavior and integration + +V2 should support the user's shell out of the box, similar to VS Code. + +That means: + +- launch the user's configured or default shell +- preserve normal shell startup behavior users expect +- make PATH, version managers, aliases, and shell config work without manual + terminal setup + +Use a hard-coded fallback shell only as a last resort: + +- macOS/Linux: prefer inherited `SHELL`, then `/bin/sh` +- Windows: prefer inherited `COMSPEC`, then `cmd.exe` + +Do not default to `/bin/zsh` just because the current implementation does. + +Shell integration is in scope for v2. + +Follow the VS Code and kitty pattern: + +- use private bootstrap vars per shell only for startup +- examples: `ZDOTDIR`, `BASH_ENV`, `XDG_DATA_DIRS` +- clean them up after shell initialization when possible + +Do not expose those bootstrap vars as part of the public v2 terminal contract. + +Supported shells for the first v2 implementation: + +- `zsh` +- `bash` +- `fish` +- `sh` and `ksh` as reduced-functionality login-shell fallbacks + +Unsupported shells should still launch natively, but without Superset-specific +shell bootstrap beyond the base env contract. + +Per-shell integration design: + +- `zsh` + - use wrapper startup through `ZDOTDIR` + - set `SUPERSET_ORIG_ZDOTDIR` and temporary `ZDOTDIR` + - launch as a login shell +- `bash` + - use the generated Superset rcfile when available + - launch with `--rcfile ` +- `fish` + - use `-l --init-command ...` + - prepend Superset bin dir idempotently after fish config loads + - emit the shell-ready marker using fish-native event hooks +- `sh` and `ksh` + - launch as login shells + - no custom wrapper files in the first pass + +This means v2 should not reuse the v1 desktop terminal env builder as-is, but +it should reuse the proven shell integration behavior and path conventions. + +`apps/desktop/src/main/lib/terminal/env.ts` currently mixes together: + +- safe env filtering +- shell wrapper bootstrap +- theme hints like `COLORFGBG` +- legacy Superset notification metadata + +That builder should remain v1-oriented. + +Instead, v2 should have a separate shell launch config layer that produces: + +- `shell` +- `args` +- private bootstrap env + +from: + +- resolved shell path +- `SUPERSET_HOME_DIR` +- wrapper file availability + +### 7. Dynamic state + +Do not use env vars for: + +- cwd updates after launch +- prompt boundaries +- command start/end markers +- exit status + +If v2 needs those later, use shell integration and OSC sequences instead. + +## Current implementation constraints + +### Host-service launch env + +`apps/desktop/src/main/lib/host-service-manager.ts` should keep responsibility +for launching host-service with the runtime env it needs. + +Host-service itself must resolve and preserve the dedicated shell snapshot used +for PTY construction. PTYs must not be derived from desktop main or the live +host-service `process.env`. + +### PTY context available in host-service + +The host-service terminal session currently has first-class access to: + +- `terminalId` +- `workspaceId` +- workspace `worktreePath` + +Host-service can also derive: + +- repo root path by joining workspace -> project and reading `projects.repoPath` + +Host-service does not currently store a dedicated workspace display name in its +SQLite schema. + +Implication: + +- `SUPERSET_TERMINAL_ID`, `SUPERSET_WORKSPACE_ID`, and + `SUPERSET_WORKSPACE_PATH` are straightforward +- `SUPERSET_ROOT_PATH` is straightforward with a join +- `SUPERSET_WORKSPACE_NAME` should not be part of the first v2 PTY contract + +Do not invent a display name from `branch` or `id`. + +## Files to update + +Primary implementation targets: + +- `apps/desktop/src/main/lib/host-service-manager.ts` +- `apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts` +- `packages/host-service/src/terminal/terminal.ts` +- new: `packages/host-service/src/terminal/env.ts` +- new: `packages/host-service/src/terminal/env-strip.ts` +- new: `packages/host-service/src/terminal/shell-launch.ts` +- `apps/desktop/src/main/host-service/index.ts` (desktop entry point for host-service) + +Secondary follow-up targets: + +- `apps/desktop/src/main/lib/terminal/env.ts` + only to clarify that it is the legacy v1 builder +- `apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh` +- `apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.sh` +- `apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.sh` +- `apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.sh` +- `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts` +- `apps/desktop/docs/EXTERNAL_FILES.md` + +## Implementation plan + +1. Tighten host-service spawn env in + `apps/desktop/src/main/lib/host-service-manager.ts`. + + Implement a strict helper: + + - `resolveTerminalShellSnapshot(): Promise>` + + Required behavior: + + - call `getStrictShellEnvironment()`, which spawns the user's login shell + with a minimal parent env (clean spawn) so Vite `.env` secrets never + contaminate the snapshot + - if shell resolution fails, throw — do not fall back to `process.env` or + any filtered variant + + This policy is final for v2: + + - shell resolution failure is terminal-blocking + - raw `process.env` passthrough is not allowed + - filtered desktop-runtime fallback is not allowed + +2. Build the final host-service process env explicitly in + `apps/desktop/src/main/lib/host-service-manager.ts`. + + Replace the current `buildHostServiceEnv()` implementation with: + + - `shellSnapshot` from `resolveTerminalShellSnapshot()` + - explicit runtime additions only + + The final host-service env must contain exactly: + + - all keys from `shellSnapshot` + - `ELECTRON_RUN_AS_NODE=1` + - `ORGANIZATION_ID` + - `DEVICE_CLIENT_ID` + - `DEVICE_NAME` + - `HOST_SERVICE_SECRET` + - `HOST_SERVICE_VERSION` + - `HOST_MANIFEST_DIR` + - `KEEP_ALIVE_AFTER_PARENT=1` + - `HOST_DB_PATH` + - `HOST_MIGRATIONS_PATH` + - `DESKTOP_VITE_PORT` + - `SUPERSET_HOME_DIR` + - `SUPERSET_AGENT_HOOK_PORT` + - `SUPERSET_AGENT_HOOK_VERSION` + - `AUTH_TOKEN` only when present + - `CLOUD_API_URL` only when present + + Source of each value: + + - `DESKTOP_VITE_PORT` comes from `shared/env.shared.ts` + - `SUPERSET_AGENT_HOOK_PORT` comes from + `shared/env.shared.ts` as `DESKTOP_NOTIFICATIONS_PORT` + - `SUPERSET_AGENT_HOOK_VERSION` comes from the existing + `HOOK_PROTOCOL_VERSION` constant for this change + - `SUPERSET_HOME_DIR` comes from the already-resolved desktop app env + + Do not start from `...(process.env as Record)`. + + Also persist the original `shellSnapshot` in host-service as the dedicated + PTY base env. PTY construction must use that preserved snapshot, not + host-service `process.env`. + +3. Add `packages/host-service/src/terminal/env.ts` as the single source of + truth for v2 PTY env construction. + + Required exports: + + - `resolveLaunchShell(baseEnv: Record): string` + - `normalizeUtf8Locale(baseEnv: Record): string` + - `getSupersetShellPaths(supersetHomeDir: string): { BIN_DIR: string; ZSH_DIR: string; BASH_DIR: string }` + - `getShellBootstrapEnv(params): Record` + - `getShellLaunchArgs(params): string[]` + - `stripTerminalRuntimeEnv(baseEnv: Record): Record` + - `buildV2TerminalEnv(params): Record` + +4. Make `resolveLaunchShell(...)` deterministic. + + Required behavior: + + - on Windows: `baseEnv.COMSPEC || "cmd.exe"` + - on non-Windows: `baseEnv.SHELL || "/bin/sh"` + + Do not default to `/bin/zsh`. + +5. Make shell integration deterministic in + `packages/host-service/src/terminal/env.ts`. + + Reuse the existing desktop shell behavior exactly: + + - `zsh` + - shell args: `["-l"]` + - private bootstrap env: + - `SUPERSET_ORIG_ZDOTDIR = baseEnv.ZDOTDIR || baseEnv.HOME || homedir()` + - `ZDOTDIR = /zsh` + - only apply this bootstrap when `/zsh/.zshrc` exists + - `bash` + - shell args: `["--rcfile", "/bash/rcfile"]` + - if the rcfile does not exist, fall back to `["-l"]` + - no bootstrap env keys + - `fish` + - shell args: + `["-l", "--init-command", ""]` + - no bootstrap env keys + - `sh` and `ksh` + - shell args: `["-l"]` + - no bootstrap env keys + - all other shells + - shell args: `[]` + - no bootstrap env keys + + Desktop remains responsible for creating: + + - `/bin` + - `/zsh` + - `/bash` + + Host-service is responsible for selecting shell args and bootstrap env. + +6. Make PTY env filtering deterministic in + `stripTerminalRuntimeEnv(...)`. + + Start from the dedicated terminal base env snapshot captured from the user's + shell, not from a snapshot of host-service `process.env`. + + Remove these exact runtime keys: + + - `AUTH_TOKEN` + - `CLOUD_API_URL` + - `DESKTOP_VITE_PORT` + - `DEVICE_CLIENT_ID` + - `DEVICE_NAME` + - `ELECTRON_RUN_AS_NODE` + - `HOST_DB_PATH` + - `HOST_MANIFEST_DIR` + - `HOST_MIGRATIONS_PATH` + - `HOST_SERVICE_SECRET` + - `HOST_SERVICE_VERSION` + - `KEEP_ALIVE_AFTER_PARENT` + - `ORGANIZATION_ID` + + Remove these exact Node and app keys: + + - `NODE_ENV` + - `NODE_OPTIONS` + - `NODE_PATH` + + Remove keys with these prefixes: + + - `npm_` + - `npm_config_` + - `ELECTRON_` + - `VITE_` + - `NEXT_PUBLIC_` + - `TURBO_` + + Treat these categories as internal runtime env, not terminal env: + + - `HOST_*` + - `DESKTOP_*` + - `DEVICE_*` + - non-kept `SUPERSET_*` + + Keep these explicit Superset support keys when present: + + - `SUPERSET_HOME_DIR` + - `SUPERSET_AGENT_HOOK_PORT` + - `SUPERSET_AGENT_HOOK_VERSION` + + Do not preserve any other `SUPERSET_*` keys by prefix rule. + +7. Make PTY env construction deterministic in `buildV2TerminalEnv(...)`. + + `buildV2TerminalEnv(...)` must: + + - start from `stripTerminalRuntimeEnv(baseEnv)` + - merge private shell bootstrap env from `getShellBootstrapEnv(...)` + - inject or override: + - `TERM=xterm-256color` + - `TERM_PROGRAM=Superset` + - `TERM_PROGRAM_VERSION=` + - `COLORTERM=truecolor` + - `LANG=` + - `PWD=` + - `SUPERSET_TERMINAL_ID=` + - `SUPERSET_WORKSPACE_ID=` + - `SUPERSET_WORKSPACE_PATH=` + - `SUPERSET_ROOT_PATH=` + - `SUPERSET_ENV=` + - `SUPERSET_AGENT_HOOK_PORT=` + - `SUPERSET_AGENT_HOOK_VERSION=` + + `SUPERSET_WORKSPACE_NAME` is not part of the v2 PTY env. + +8. Update `packages/host-service/src/terminal/terminal.ts`. + + `createTerminalSessionInternal(...)` must: + + - query the workspace as it does now + - query the related project to derive `rootPath` + - load the preserved shell snapshot for PTY env construction + - resolve `shell` via `resolveLaunchShell(shellSnapshot)` + - resolve `shellArgs` via `getShellLaunchArgs(...)` + - build `ptyEnv` via `buildV2TerminalEnv(...)` + - call `spawn(shell, shellArgs, { name: "xterm-256color", cwd, cols, rows, env: ptyEnv })` + + It must not read host-service `process.env` as the PTY base env. + + It must no longer call `spawn(resolveShell(), [], { env: { ...process.env, ... } })`. + +9. Keep v1 and v2 separate. + + - do not make v2 call `apps/desktop/src/main/lib/terminal/env.ts` + - do not make v2 reuse blanket `SUPERSET_*` passthrough + - do not change v1 desktop terminal behavior in this change + +## Acceptance criteria + +- v2 host-service no longer spawns PTYs from raw `process.env` +- v2 host-service no longer uses host-service `process.env` as the PTY base env +- v2 host-service launch env no longer starts from raw desktop `process.env` +- v2 terminal creation fails closed when a real shell snapshot cannot be + resolved +- user-needed shell env still works for normal tools and version managers +- zsh, bash, and fish launch with Superset shell integration behavior +- v2 PTY env includes `TERM_PROGRAM=Superset` +- v2 PTY env includes `SUPERSET_TERMINAL_ID` +- v2 PTY env includes `SUPERSET_WORKSPACE_ID` +- v2 PTY env includes `SUPERSET_WORKSPACE_PATH` +- v2 PTY env includes `SUPERSET_ROOT_PATH` when it is derivable +- v2 PTY env includes `SUPERSET_AGENT_HOOK_PORT` +- v2 PTY env includes `SUPERSET_AGENT_HOOK_VERSION` +- v2 PTY env does not include `SUPERSET_PANE_ID` +- v2 PTY env does not include `SUPERSET_TAB_ID` +- v2 PTY env does not include `SUPERSET_PORT` +- v2 PTY env does not include `SUPERSET_HOOK_VERSION` +- v2 PTY env does not require `SUPERSET_WORKSPACE_NAME` +- the v2 contract is defined in one place and documented + +## Tests + +Add or update tests around behavior regressions and boundary protection, not +around every field assignment. + +Required test coverage: + +- shell snapshot path + - when a shell-derived env contains user PATH/tooling vars that are missing + from app env, PTY env preserves them + - PTY env is built from the preserved shell snapshot, not live host-service + `process.env` + - when shell resolution fails, terminal creation fails explicitly instead of + falling back to desktop or host-service runtime env + +- leakage prevention + - app/runtime secrets do not reach PTY env + - host-service control vars do not reach PTY env + - dev-runner and Electron runtime vars do not reach PTY env: + `npm_*`, `npm_config_*`, `ELECTRON_*` + - removed legacy vars do not reach PTY env: + `SUPERSET_PANE_ID`, `SUPERSET_TAB_ID`, `SUPERSET_PORT`, + `SUPERSET_HOOK_VERSION` + +- retained contract behavior + - the minimal v2 Superset metadata needed by real consumers is present: + `SUPERSET_TERMINAL_ID`, `SUPERSET_WORKSPACE_ID`, + `SUPERSET_WORKSPACE_PATH`, `SUPERSET_AGENT_HOOK_PORT`, + `SUPERSET_AGENT_HOOK_VERSION` + - `TERM_PROGRAM=Superset` and a UTF-8 locale are present + +- shell launch behavior + - zsh launch config applies wrapper bootstrap only when wrapper files exist + and otherwise degrades safely + - bash launch config uses rcfile when present and login-shell fallback when + absent + - fish launch config uses the expected init-command path and does not crash + - unsupported shells launch natively without Superset-specific bootstrap + +- workspace-derived metadata + - `SUPERSET_ROOT_PATH` is populated when project data is available + - missing project/root metadata degrades to empty string rather than failure + +- one integration-level PTY spawn test + - host-service terminal session creation uses the preserved shell snapshot + plus built env rather than `spawn(..., [], { env: { ...process.env } })` + +Avoid low-value tests that only restate helper internals line-by-line or assert +every single key in isolation without covering a real regression risk. + +Recommended test location: + +- `packages/host-service/src/terminal/env.test.ts` +- targeted integration coverage near `packages/host-service/src/terminal/terminal.ts` + +## Non-goals + +- recreating the full v1 desktop hook contract unchanged +- using env vars for dynamic runtime session state + +## Notes for implementation + +- `apps/desktop/src/main/lib/terminal/env.ts` is not the right shared source + for v2 because it is coupled to v1 desktop shell wrappers and legacy + notification env names +- the pure shell launch logic in `apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts` + is the right behavioral reference for zsh, bash, and fish support +- `packages/host-service/src/terminal/terminal.ts` currently only has + `workspaceId` on websocket attach, so launch cwd remains the workspace + worktree path for this change +- `SUPERSET_WORKSPACE_NAME` is intentionally omitted from the first v2 PTY + contract because there is no clean host-service source for it and no concrete + v2 runtime consumer requiring it