Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export const createHostServiceManagerRouter = () => {
}
manager.setCloudApiUrl(env.NEXT_PUBLIC_API_URL);
const port = await manager.start(input.organizationId);
return { port };
const secret = manager.getSecret(input.organizationId);
return { port, secret };
}),

getStatus: publicProcedure
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { listExternalWorktrees } from "../utils/git";

/**
* Integration tests for external worktree auto-import feature
Expand Down Expand Up @@ -116,10 +117,6 @@ describe("External worktree detection and import", () => {
// Create external worktree
createExternalWorktree(mainRepoPath, "feature-test", externalWorktreePath);

// Import the listExternalWorktrees function
const { listExternalWorktrees } = await import("../utils/git");

// List external worktrees
const externalWorktrees = await listExternalWorktrees(mainRepoPath);

// Find our external worktree
Expand Down
11 changes: 9 additions & 2 deletions apps/desktop/src/main/host-service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,33 @@
import { serve } from "@hono/node-server";
import {
createApp,
JwtAuthProvider,
JwtApiAuthProvider,
LocalGitCredentialProvider,
PskHostAuthProvider,
} from "@superset/host-service";

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 auth =
authToken && cloudApiUrl ? new JwtAuthProvider(authToken) : undefined;
authToken && cloudApiUrl ? new JwtApiAuthProvider(authToken) : undefined;
const hostAuth = hostServiceSecret
? new PskHostAuthProvider(hostServiceSecret)
: undefined;
Comment on lines +27 to +29
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Fail closed when HOST_SERVICE_SECRET is missing. The current fallback to undefined disables host auth entirely, leaving the WebSocket routes unauthenticated if the env var isn’t set.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/main/host-service/index.ts, line 27:

<comment>Fail closed when HOST_SERVICE_SECRET is missing. The current fallback to `undefined` disables host auth entirely, leaving the WebSocket routes unauthenticated if the env var isn’t set.</comment>

<file context>
@@ -10,26 +10,33 @@
 const auth =
-	authToken && cloudApiUrl ? new JwtAuthProvider(authToken) : undefined;
+	authToken && cloudApiUrl ? new JwtApiAuthProvider(authToken) : undefined;
+const hostAuth = hostServiceSecret
+	? new PskHostAuthProvider(hostServiceSecret)
+	: undefined;
</file context>
Suggested change
const hostAuth = hostServiceSecret
? new PskHostAuthProvider(hostServiceSecret)
: undefined;
if (!hostServiceSecret) {
throw new Error("HOST_SERVICE_SECRET is required to start host-service");
}
const hostAuth = new PskHostAuthProvider(hostServiceSecret);
Fix with Cubic


const { app, injectWebSocket } = createApp({
credentials: new LocalGitCredentialProvider(),
auth,
hostAuth,
cloudApiUrl,
dbPath,
deviceClientId,
deviceName,
allowedOrigins: ["http://127.0.0.1"],
});

const server = serve(
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/src/main/lib/host-service-manager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ChildProcess } from "node:child_process";
import * as childProcess from "node:child_process";
import { randomBytes } from "node:crypto";
import path from "node:path";
import { app } from "electron";
import { getProcessEnvWithShellPath } from "../../lib/trpc/routers/workspaces/utils/shell-env";
Expand All @@ -11,6 +12,7 @@ type HostServiceStatus = "starting" | "running" | "crashed";
interface HostServiceProcess {
process: ChildProcess | null;
port: number | null;
secret: string | null;
status: HostServiceStatus;
restartCount: number;
lastCrash?: number;
Expand Down Expand Up @@ -91,6 +93,10 @@ export class HostServiceManager {
return this.instances.get(organizationId)?.port ?? null;
}

getSecret(organizationId: string): string | null {
return this.instances.get(organizationId)?.secret ?? null;
}

getStatus(organizationId: string): HostServiceStatus | null {
if (this.pendingStarts.has(organizationId)) {
return "starting";
Expand All @@ -100,9 +106,11 @@ export class HostServiceManager {

private async spawn(organizationId: string): Promise<number> {
const pendingStart = createPortDeferred();
const secret = randomBytes(32).toString("hex");
const instance: HostServiceProcess = {
process: null,
port: null,
secret,
status: "starting",
restartCount: 0,
organizationId,
Expand All @@ -111,7 +119,7 @@ export class HostServiceManager {
this.pendingStarts.set(organizationId, pendingStart);

try {
const env = await this.buildHostServiceEnv(organizationId);
const env = await this.buildHostServiceEnv(organizationId, secret);
if (this.authToken) {
env.AUTH_TOKEN = this.authToken;
}
Expand Down Expand Up @@ -152,13 +160,15 @@ export class HostServiceManager {

private async buildHostServiceEnv(
organizationId: string,
secret: string,
): Promise<Record<string, string>> {
return getProcessEnvWithShellPath({
...(process.env as Record<string, string>),
ELECTRON_RUN_AS_NODE: "1",
ORGANIZATION_ID: organizationId,
DEVICE_CLIENT_ID: getHashedDeviceId(),
DEVICE_NAME: getDeviceName(),
HOST_SERVICE_SECRET: secret,
HOST_DB_PATH: path.join(
SUPERSET_HOME_DIR,
"host",
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/renderer/lib/host-service-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const secrets = new Map<string, string>();

export function setHostServiceSecret(hostUrl: string, secret: string): void {
secrets.set(hostUrl, secret);
}

export function removeHostServiceSecret(hostUrl: string): void {
secrets.delete(hostUrl);
}

export function getHostServiceHeaders(hostUrl: string): Record<string, string> {
const secret = secrets.get(hostUrl);
return secret ? { Authorization: `Bearer ${secret}` } : {};
}

export function getHostServiceWsToken(hostUrl: string): string | null {
return secrets.get(hostUrl) ?? null;
}
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/lib/host-service-client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AppRouter } from "@superset/host-service";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import { getHostServiceHeaders } from "./host-service-auth";

const clientCache = new Map<
string,
Expand All @@ -22,6 +23,7 @@ export function getHostServiceClientByUrl(hostUrl: string): HostServiceClient {
httpBatchLink({
url: `${hostUrl}/trpc`,
transformer: superjson,
headers: () => getHostServiceHeaders(hostUrl),
}),
],
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { Button } from "@superset/ui/button";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal as XTerm } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { useEffect, useMemo, useRef, useState } from "react";
import { useWorkspaceHostUrl } from "../../../providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider";
import { useEffect, useRef, useState } from "react";
import { useWorkspaceWsUrl } from "../../../providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider";

interface WorkspaceTerminalProps {
workspaceId: string;
Expand All @@ -25,19 +25,15 @@ type TerminalServerMessage =
};

export function WorkspaceTerminal({ workspaceId }: WorkspaceTerminalProps) {
const hostUrl = useWorkspaceHostUrl();
const containerRef = useRef<HTMLDivElement | null>(null);
const [connectionState, setConnectionState] = useState<
"connecting" | "open" | "closed"
>("connecting");
const [reconnectKey, setReconnectKey] = useState(0);

const websocketUrl = useMemo(() => {
const url = new URL(`/terminal/${workspaceId}`, hostUrl);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.searchParams.set("reconnect", String(reconnectKey));
return url.toString();
}, [hostUrl, reconnectKey, workspaceId]);
const websocketUrl = useWorkspaceWsUrl(`/terminal/${workspaceId}`, {
reconnect: String(reconnectKey),
});

useEffect(() => {
const container = containerRef.current;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { useLiveQuery } from "@tanstack/react-db";
import { createFileRoute, Outlet, useMatchRoute } from "@tanstack/react-router";
import { useEffect, useRef } from "react";
import { electronTrpc } from "renderer/lib/electron-trpc";
import {
getHostServiceHeaders,
getHostServiceWsToken,
} from "renderer/lib/host-service-auth";
import { getWorkspaceHostUrlForWorkspace } from "renderer/lib/v2-workspace-host";
import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
Expand Down Expand Up @@ -53,12 +57,14 @@ function V2WorkspaceLayout() {
? (services.get(workspace.organizationId)?.url ?? null)
: null;
const shouldWaitForDeviceInfo = workspace !== null && isDeviceInfoPending;
const isLocal = workspace?.deviceId === currentDevice?.id;
const hostUrl =
!workspace || shouldWaitForDeviceInfo
? null
: workspace.deviceId === currentDevice?.id
: isLocal
? localHostUrl
: getWorkspaceHostUrlForWorkspace(workspace.id);

const lastEnsuredWorkspaceIdRef = useRef<string | null>(null);

useEffect(() => {
Expand Down Expand Up @@ -93,6 +99,8 @@ function V2WorkspaceLayout() {
cacheKey={workspace.id}
key={`${workspace.id}:${hostUrl}`}
hostUrl={hostUrl}
headers={() => getHostServiceHeaders(hostUrl)}
wsToken={() => getHostServiceWsToken(hostUrl)}
>
<Outlet />
</WorkspaceTrpcProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export {
useWorkspaceHostUrl,
useWorkspaceWsUrl,
WorkspaceClientProvider as WorkspaceTrpcProvider,
} from "@superset/workspace-client";
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { env } from "renderer/env.renderer";
import { authClient } from "renderer/lib/auth-client";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { setHostServiceSecret } from "renderer/lib/host-service-auth";
import {
getHostServiceClient,
type HostServiceClient,
Expand Down Expand Up @@ -73,10 +74,14 @@ export function HostServiceProvider({ children }: { children: ReactNode }) {
const services = useMemo(() => {
const map = new Map<string, OrgService>();

const addOrg = (orgId: string, port: number) => {
const addOrg = (orgId: string, port: number, secret: string | null) => {
const url = `http://127.0.0.1:${port}`;
if (secret) {
setHostServiceSecret(url, secret);
}
Comment on lines +77 to +81
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify render-phase mutation inside useMemo in HostServiceProvider.
rg -n -C3 'useMemo\(|setHostServiceSecret\(' apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx

Repository: superset-sh/superset

Length of output: 809


🏁 Script executed:

# Locate and examine setHostServiceSecret implementation
fd -e ts -e tsx "host-service-auth" apps/desktop/src/renderer/

Repository: superset-sh/superset

Length of output: 114


🏁 Script executed:

# Also check the full HostServiceProvider file to understand context
wc -l apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx

Repository: superset-sh/superset

Length of output: 169


🏁 Script executed:

# Read the setHostServiceSecret implementation
cat -n apps/desktop/src/renderer/lib/host-service-auth.ts

Repository: superset-sh/superset

Length of output: 752


🏁 Script executed:

# Read the full HostServiceProvider to understand context and dependencies
cat -n apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx

Repository: superset-sh/superset

Length of output: 4364


Move secret synchronization out of useMemo and into useEffect.

At line 80, setHostServiceSecret mutates module-level auth state during render. In React's strict mode and concurrent rendering, render can be replayed or aborted, causing stale mutations to persist. Keep the memoized function pure and sync secrets in a committed effect.

♻️ Suggested refactor
-import { setHostServiceSecret } from "renderer/lib/host-service-auth";
+import {
+	removeHostServiceSecret,
+	setHostServiceSecret,
+} from "renderer/lib/host-service-auth";

 export interface OrgService {
 	port: number;
 	url: string;
 	client: HostServiceClient;
+	secret: string | null;
 }

 const services = useMemo(() => {
 	const map = new Map<string, OrgService>();

 	const addOrg = (orgId: string, port: number, secret: string | null) => {
 		const url = `http://127.0.0.1:${port}`;
-		if (secret) {
-			setHostServiceSecret(url, secret);
-		}
 		map.set(orgId, {
 			port,
 			url,
 			client: getHostServiceClient(port),
+			secret,
 		});
 	};
 	...
 }, [orgIds, utils, activeOrganizationId, activePortData]);

+useEffect(() => {
+	const currentUrls = new Set<string>();
+	for (const service of services.values()) {
+		currentUrls.add(service.url);
+		if (service.secret) {
+			setHostServiceSecret(service.url, service.secret);
+		} else {
+			removeHostServiceSecret(service.url);
+		}
+	}
+	return () => {
+		for (const url of currentUrls) {
+			removeHostServiceSecret(url);
+		}
+	};
+}, [services]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx`
around lines 77 - 81, addOrg (inside the useMemo) is performing a side-effect by
calling setHostServiceSecret during render; remove that mutation from the
memoized addOrg to keep it pure, and instead synchronize the secret in a
committed effect: have addOrg only compute/return the url (and update any local
state like host/port/secret if needed), then add a useEffect that depends on the
secret/url state and calls setHostServiceSecret(url, secret) when both are
present; reference addOrg, setHostServiceSecret, the existing useMemo, and
replace the in-render call with a useEffect-based call so secret sync runs only
after commit.

map.set(orgId, {
port,
url: `http://127.0.0.1:${port}`,
url,
client: getHostServiceClient(port),
});
Comment on lines +77 to 86
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there's any subscription or event mechanism for host-service restarts
rg -n "subscription|onRestart|secretChanged" --type=ts apps/desktop/src/lib/trpc/routers/host-service-manager/
rg -n "subscription|onRestart|secretChanged" --type=ts apps/desktop/src/main/lib/host-service-manager.ts

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# First, check the full HostServiceProvider implementation
cat -n apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx

Repository: superset-sh/superset

Length of output: 4364


🏁 Script executed:

# Check the host-service-manager implementation to see how secrets are generated and managed
cat -n apps/desktop/src/main/lib/host-service-manager.ts

Repository: superset-sh/superset

Length of output: 11494


🏁 Script executed:

# Check if removeHostServiceSecret is ever called/used
rg -n "removeHostServiceSecret" --type=ts apps/desktop/

Repository: superset-sh/superset

Length of output: 182


🏁 Script executed:

# Check how getHostServiceClient is implemented and if it has 401 retry logic
fd -e ts -e tsx ".*" apps/desktop/src/lib/ | xargs rg -l "getHostServiceClient"

Repository: superset-sh/superset

Length of output: 48


🏁 Script executed:

# Find the getLocalPort router implementation
fd -type f "host-service-manager" apps/desktop/src/lib/trpc/routers/ | head -5

Repository: superset-sh/superset

Length of output: 234


🏁 Script executed:

# Search for getLocalPort query definition
rg -n "getLocalPort" --type=ts apps/desktop/src/lib/trpc/routers/

Repository: superset-sh/superset

Length of output: 161


🏁 Script executed:

# Check removeHostServiceSecret usage throughout the codebase
rg -n "removeHostServiceSecret" --type=ts apps/desktop/

Repository: superset-sh/superset

Length of output: 182


🏁 Script executed:

# Read the full host-service-manager router implementation
cat -n apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts

Repository: superset-sh/superset

Length of output: 1313


🏁 Script executed:

# Double-check removeHostServiceSecret usage with more context
rg -B2 -A2 "removeHostServiceSecret" --type ts apps/desktop/

Repository: superset-sh/superset

Length of output: 414


🏁 Script executed:

# Verify if there's any 401 retry logic in the host-service client
cat -n apps/desktop/src/renderer/lib/host-service-client.ts

Repository: superset-sh/superset

Length of output: 1228


🏁 Script executed:

# Check if there's any mechanism to invalidate or refetch on app focus/resume
rg -n "refetch|invalidate|onFocus" --type ts apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/

Repository: superset-sh/superset

Length of output: 46


Stale secret after host-service restart causes authentication failures.

When the host-service crashes and respawns, spawn() generates a new secret, but getLocalPort is a query (not a subscription). The renderer's useQuery() only refreshes when activeOrganizationId changes. Since a service restart doesn't change the organization ID, clients continue using the cached stale secret and receive 401 errors until getLocalPort is manually re-queried.

The removeHostServiceSecret() function is also dead code—never called anywhere in the codebase.

Consider either:

  1. Add a tRPC subscription that emits when a restart occurs with the new { port, secret }
  2. Implement 401 retry logic that re-fetches the secret on authentication failures
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx`
around lines 77 - 86, The renderer is using a cached secret (set via
setHostServiceSecret in addOrg) so when spawn() restarts host-service and issues
a new secret the client (created by getHostServiceClient) keeps using the stale
secret from the getLocalPort query; removeHostServiceSecret is unused. Fix by
implementing retry-on-401 in the client code: detect 401 responses from the
client returned by getHostServiceClient, call a function that re-queries the
latest port/secret (re-run the getLocalPort query or call a new tRPC helper),
update the secret via setHostServiceSecret, recreate the client for that orgId
(the map entry created in addOrg) and retry the failing request once;
alternatively implement a tRPC subscription that emits {port, secret} on spawn
and wire it to update the map and setHostServiceSecret so clients always get the
latest secret. Ensure removeHostServiceSecret is either removed or used
consistently.

};
Expand All @@ -86,7 +91,7 @@ export function HostServiceProvider({ children }: { children: ReactNode }) {
organizationId: orgId,
});
if (cached?.port) {
addOrg(orgId, cached.port);
addOrg(orgId, cached.port, cached.secret ?? null);
}
}

Expand All @@ -96,7 +101,11 @@ export function HostServiceProvider({ children }: { children: ReactNode }) {
activePortData?.port &&
!map.has(activeOrganizationId)
) {
addOrg(activeOrganizationId, activePortData.port);
addOrg(
activeOrganizationId,
activePortData.port,
activePortData.secret ?? null,
);
}

return map;
Expand Down
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { AppRouter } from "@superset/trpc";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import SuperJSON from "superjson";
import type { AuthProvider } from "../../providers/auth";
import type { ApiAuthProvider } from "../../providers/auth";
import type { ApiClient } from "../../types";

export function createApiClient(
baseUrl: string,
authProvider: AuthProvider,
authProvider: ApiAuthProvider,
): ApiClient {
return createTRPCClient<AppRouter>({
links: [
Expand Down
Loading
Loading