From c2e797e8941bea5b11ee9c23e0646bd0bcd4daec Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 26 Mar 2026 19:56:02 -0400 Subject: [PATCH 1/6] Clean enougH --- .../routers/host-service-manager/index.ts | 3 +- apps/desktop/src/main/host-service/index.ts | 11 +++- .../src/main/lib/host-service-manager.ts | 12 ++++- .../src/renderer/lib/host-service-auth.ts | 30 +++++++++++ .../src/renderer/lib/host-service-client.ts | 2 + .../WorkspaceTerminal/WorkspaceTerminal.tsx | 14 ++--- .../_dashboard/v2-workspace/layout.tsx | 10 +++- .../WorkspaceTrpcProvider.tsx | 1 + .../HostServiceProvider.tsx | 17 ++++-- bun.lock | 2 +- .../api/createApiClient/createApiClient.ts | 4 +- packages/host-service/src/app.ts | 49 ++++++++++++++--- packages/host-service/src/env.ts | 17 ++++++ packages/host-service/src/index.ts | 9 ++-- .../DeviceKeyAuthProvider.ts | 4 +- .../auth/DeviceKeyAuthProvider/index.ts | 2 +- .../auth/JwtAuthProvider/JwtAuthProvider.ts | 4 +- .../providers/auth/JwtAuthProvider/index.ts | 2 +- .../host-service/src/providers/auth/index.ts | 6 +-- .../host-service/src/providers/auth/types.ts | 2 +- .../PskHostAuthProvider.ts | 26 +++++++++ .../host-auth/PskHostAuthProvider/index.ts | 1 + .../src/providers/host-auth/index.ts | 2 + .../src/providers/host-auth/types.ts | 6 +++ packages/host-service/src/serve.ts | 14 +++-- packages/host-service/src/trpc/index.ts | 12 ++++- .../host-service/src/trpc/router/chat/chat.ts | 26 ++++----- .../src/trpc/router/cloud/cloud.ts | 4 +- .../src/trpc/router/filesystem/filesystem.ts | 22 ++++---- .../host-service/src/trpc/router/git/git.ts | 4 +- .../src/trpc/router/github/github.ts | 18 +++---- .../src/trpc/router/project/project.ts | 4 +- .../router/pull-requests/pull-requests.ts | 6 +-- .../src/trpc/router/workspace/workspace.ts | 10 ++-- packages/host-service/src/types.ts | 1 + packages/scripts/package.json | 9 ---- packages/scripts/tsconfig.json | 10 ---- packages/workspace-client/src/index.ts | 1 + .../WorkspaceClientProvider.tsx | 53 +++++++++++++++++-- .../WorkspaceClientProvider/index.ts | 1 + 40 files changed, 314 insertions(+), 117 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/host-service-auth.ts create mode 100644 packages/host-service/src/env.ts create mode 100644 packages/host-service/src/providers/host-auth/PskHostAuthProvider/PskHostAuthProvider.ts create mode 100644 packages/host-service/src/providers/host-auth/PskHostAuthProvider/index.ts create mode 100644 packages/host-service/src/providers/host-auth/index.ts create mode 100644 packages/host-service/src/providers/host-auth/types.ts delete mode 100644 packages/scripts/package.json delete mode 100644 packages/scripts/tsconfig.json diff --git a/apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts b/apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts index eb29dbe3c49..5ee7b4f1ffe 100644 --- a/apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts +++ b/apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts @@ -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 diff --git a/apps/desktop/src/main/host-service/index.ts b/apps/desktop/src/main/host-service/index.ts index 6d624fc4c84..56da823a9d2 100644 --- a/apps/desktop/src/main/host-service/index.ts +++ b/apps/desktop/src/main/host-service/index.ts @@ -10,8 +10,9 @@ import { serve } from "@hono/node-server"; import { createApp, - JwtAuthProvider, + JwtApiAuthProvider, LocalGitCredentialProvider, + PskHostAuthProvider, } from "@superset/host-service"; const authToken = process.env.AUTH_TOKEN; @@ -19,17 +20,23 @@ 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; const { app, injectWebSocket } = createApp({ credentials: new LocalGitCredentialProvider(), auth, + hostAuth, cloudApiUrl, dbPath, deviceClientId, deviceName, + allowedOrigins: ["http://127.0.0.1"], }); const server = serve( diff --git a/apps/desktop/src/main/lib/host-service-manager.ts b/apps/desktop/src/main/lib/host-service-manager.ts index 7fe115fd8ec..b70099981fc 100644 --- a/apps/desktop/src/main/lib/host-service-manager.ts +++ b/apps/desktop/src/main/lib/host-service-manager.ts @@ -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"; @@ -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; @@ -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"; @@ -100,9 +106,11 @@ export class HostServiceManager { private async spawn(organizationId: string): Promise { const pendingStart = createPortDeferred(); + const secret = randomBytes(32).toString("hex"); const instance: HostServiceProcess = { process: null, port: null, + secret, status: "starting", restartCount: 0, organizationId, @@ -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; } @@ -152,6 +160,7 @@ export class HostServiceManager { private async buildHostServiceEnv( organizationId: string, + secret: string, ): Promise> { return getProcessEnvWithShellPath({ ...(process.env as Record), @@ -159,6 +168,7 @@ export class HostServiceManager { ORGANIZATION_ID: organizationId, DEVICE_CLIENT_ID: getHashedDeviceId(), DEVICE_NAME: getDeviceName(), + HOST_SERVICE_SECRET: secret, HOST_DB_PATH: path.join( SUPERSET_HOME_DIR, "host", diff --git a/apps/desktop/src/renderer/lib/host-service-auth.ts b/apps/desktop/src/renderer/lib/host-service-auth.ts new file mode 100644 index 00000000000..476f8820581 --- /dev/null +++ b/apps/desktop/src/renderer/lib/host-service-auth.ts @@ -0,0 +1,30 @@ +/** + * Host-service auth registry. + * + * Module-level secret store keyed by hostUrl. HostServiceProvider writes + * secrets here; all host-service clients (tRPC + WebSocket) read lazily + * via callback headers — mirroring the api-trpc-client.ts getAuthToken() + * pattern. This is the single auth configuration point for host-service + * connections in the renderer. + */ + +const secrets = new Map(); + +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 { + const secret = secrets.get(hostUrl); + return secret ? { Authorization: `Bearer ${secret}` } : {}; +} + +export function getHostServiceWsToken(hostUrl: string): string | null { + return secrets.get(hostUrl) ?? null; +} diff --git a/apps/desktop/src/renderer/lib/host-service-client.ts b/apps/desktop/src/renderer/lib/host-service-client.ts index c438e868a30..f4e12cb57cd 100644 --- a/apps/desktop/src/renderer/lib/host-service-client.ts +++ b/apps/desktop/src/renderer/lib/host-service-client.ts @@ -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, @@ -22,6 +23,7 @@ export function getHostServiceClientByUrl(hostUrl: string): HostServiceClient { httpBatchLink({ url: `${hostUrl}/trpc`, transformer: superjson, + headers: () => getHostServiceHeaders(hostUrl), }), ], }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceTerminal/WorkspaceTerminal.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceTerminal/WorkspaceTerminal.tsx index 4040e457cf3..c605278fc2e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceTerminal/WorkspaceTerminal.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceTerminal/WorkspaceTerminal.tsx @@ -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; @@ -25,19 +25,15 @@ type TerminalServerMessage = }; export function WorkspaceTerminal({ workspaceId }: WorkspaceTerminalProps) { - const hostUrl = useWorkspaceHostUrl(); const containerRef = useRef(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; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx index 8cb7390ba38..a13c0229aed 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx @@ -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"; @@ -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(null); useEffect(() => { @@ -93,6 +99,8 @@ function V2WorkspaceLayout() { cacheKey={workspace.id} key={`${workspace.id}:${hostUrl}`} hostUrl={hostUrl} + headers={() => getHostServiceHeaders(hostUrl)} + wsToken={() => getHostServiceWsToken(hostUrl)} > diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider.tsx index bd13c44de59..6533f56e4e3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider.tsx @@ -1,4 +1,5 @@ export { useWorkspaceHostUrl, + useWorkspaceWsUrl, WorkspaceClientProvider as WorkspaceTrpcProvider, } from "@superset/workspace-client"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx index cecb02bfe74..d65e5bdc0f0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx @@ -13,6 +13,7 @@ import { getHostServiceClient, type HostServiceClient, } from "renderer/lib/host-service-client"; +import { setHostServiceSecret } from "renderer/lib/host-service-auth"; import { MOCK_ORG_ID } from "shared/constants"; import { useCollections } from "../CollectionsProvider"; @@ -73,10 +74,14 @@ export function HostServiceProvider({ children }: { children: ReactNode }) { const services = useMemo(() => { const map = new Map(); - 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); + } map.set(orgId, { port, - url: `http://127.0.0.1:${port}`, + url, client: getHostServiceClient(port), }); }; @@ -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); } } @@ -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; diff --git a/bun.lock b/bun.lock index 21669df53f5..c0200546b54 100644 --- a/bun.lock +++ b/bun.lock @@ -110,7 +110,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "1.3.2", + "version": "1.4.0", "dependencies": { "@ai-sdk/anthropic": "^3.0.43", "@ai-sdk/openai": "3.0.36", diff --git a/packages/host-service/src/api/createApiClient/createApiClient.ts b/packages/host-service/src/api/createApiClient/createApiClient.ts index f549dd4c071..8f471835a7f 100644 --- a/packages/host-service/src/api/createApiClient/createApiClient.ts +++ b/packages/host-service/src/api/createApiClient/createApiClient.ts @@ -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({ links: [ diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index 4524ac163e0..c1923978f4e 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -3,12 +3,14 @@ import { join } from "node:path"; import { createNodeWebSocket } from "@hono/node-ws"; import { trpcServer } from "@hono/trpc-server"; import { Octokit } from "@octokit/rest"; +import type { MiddlewareHandler } from "hono"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { createApiClient } from "./api"; import { createDb } from "./db"; import { registerWorkspaceFilesystemEventsRoute } from "./filesystem"; -import type { AuthProvider } from "./providers/auth"; +import type { ApiAuthProvider } from "./providers/auth"; +import type { HostAuthProvider } from "./providers/host-auth"; import { LocalGitCredentialProvider } from "./providers/git"; import { LocalModelProvider, @@ -25,11 +27,13 @@ import { appRouter } from "./trpc/router"; export interface CreateAppOptions { credentials?: GitCredentialProvider; modelProviderRuntimeResolver?: ModelProviderRuntimeResolver; - auth?: AuthProvider; + auth?: ApiAuthProvider; + hostAuth?: HostAuthProvider; cloudApiUrl?: string; dbPath?: string; deviceClientId?: string; deviceName?: string; + allowedOrigins?: string[]; } export interface CreateAppResult { @@ -78,7 +82,33 @@ export function createApp(options?: CreateAppOptions): CreateAppResult { }; const app = new Hono(); const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); - app.use("*", cors()); + + app.use( + "*", + cors({ + origin: options?.allowedOrigins ?? [], + allowHeaders: ["Content-Type", "Authorization"], + }), + ); + + // Auth guard for WebSocket routes (must be registered before route handlers) + if (options?.hostAuth) { + const hostAuth = options.hostAuth; + const wsAuth: MiddlewareHandler = async (c, next) => { + const headerValid = await hostAuth.validate(c.req.raw); + const queryToken = c.req.query("token"); + const queryValid = queryToken + ? await hostAuth.validateToken(queryToken) + : false; + if (!headerValid && !queryValid) { + return c.json({ error: "Unauthorized" }, 401); + } + return next(); + }; + app.use("/terminal/*", wsAuth); + app.use("/workspace-filesystem/*", wsAuth); + } + registerWorkspaceFilesystemEventsRoute({ app, filesystem, @@ -89,12 +119,17 @@ export function createApp(options?: CreateAppOptions): CreateAppResult { db, upgradeWebSocket, }); + + const hostAuth = options?.hostAuth; app.use( "/trpc/*", trpcServer({ router: appRouter, - createContext: async () => - ({ + createContext: async (_opts, c) => { + const isAuthenticated = hostAuth + ? await hostAuth.validate(c.req.raw) + : false; + return { git, github, api, @@ -102,7 +137,9 @@ export function createApp(options?: CreateAppOptions): CreateAppResult { runtime, deviceClientId: options?.deviceClientId ?? null, deviceName: options?.deviceName ?? null, - }) as Record, + isAuthenticated, + } as Record; + }, }), ); diff --git a/packages/host-service/src/env.ts b/packages/host-service/src/env.ts new file mode 100644 index 00000000000..e32483dead3 --- /dev/null +++ b/packages/host-service/src/env.ts @@ -0,0 +1,17 @@ +import { randomBytes } from "node:crypto"; +import { z } from "zod"; + +const envSchema = z.object({ + HOST_SERVICE_SECRET: z + .string() + .min(1) + .default(randomBytes(32).toString("hex")), + HOST_DB_PATH: z.string().min(1).optional(), + CORS_ORIGINS: z + .string() + .transform((s) => s.split(",").map((o) => o.trim())) + .optional(), + PORT: z.coerce.number().int().positive().default(4879), +}); + +export const env = envSchema.parse(process.env); diff --git a/packages/host-service/src/index.ts b/packages/host-service/src/index.ts index 44220cb554c..feb92238c0d 100644 --- a/packages/host-service/src/index.ts +++ b/packages/host-service/src/index.ts @@ -5,11 +5,10 @@ export { buildWorkspaceFilesystemEventsPath, type WorkspaceFilesystemServerMessage, } from "./filesystem"; -export type { AuthProvider } from "./providers/auth"; -export { - DeviceKeyAuthProvider, - JwtAuthProvider, -} from "./providers/auth"; +export type { ApiAuthProvider } from "./providers/auth"; +export { DeviceKeyApiAuthProvider, JwtApiAuthProvider } from "./providers/auth"; +export type { HostAuthProvider } from "./providers/host-auth"; +export { PskHostAuthProvider } from "./providers/host-auth"; export { CloudGitCredentialProvider, LocalGitCredentialProvider, diff --git a/packages/host-service/src/providers/auth/DeviceKeyAuthProvider/DeviceKeyAuthProvider.ts b/packages/host-service/src/providers/auth/DeviceKeyAuthProvider/DeviceKeyAuthProvider.ts index e400575d7be..9d7319c79ae 100644 --- a/packages/host-service/src/providers/auth/DeviceKeyAuthProvider/DeviceKeyAuthProvider.ts +++ b/packages/host-service/src/providers/auth/DeviceKeyAuthProvider/DeviceKeyAuthProvider.ts @@ -1,6 +1,6 @@ -import type { AuthProvider } from "../types"; +import type { ApiAuthProvider } from "../types"; -export class DeviceKeyAuthProvider implements AuthProvider { +export class DeviceKeyApiAuthProvider implements ApiAuthProvider { private apiKey: string; constructor(apiKey: string) { diff --git a/packages/host-service/src/providers/auth/DeviceKeyAuthProvider/index.ts b/packages/host-service/src/providers/auth/DeviceKeyAuthProvider/index.ts index b246bef93d2..edd7824078c 100644 --- a/packages/host-service/src/providers/auth/DeviceKeyAuthProvider/index.ts +++ b/packages/host-service/src/providers/auth/DeviceKeyAuthProvider/index.ts @@ -1 +1 @@ -export { DeviceKeyAuthProvider } from "./DeviceKeyAuthProvider"; +export { DeviceKeyApiAuthProvider } from "./DeviceKeyAuthProvider"; diff --git a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts index 62ecc6587b4..36428a191c2 100644 --- a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts +++ b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts @@ -1,6 +1,6 @@ -import type { AuthProvider } from "../types"; +import type { ApiAuthProvider } from "../types"; -export class JwtAuthProvider implements AuthProvider { +export class JwtApiAuthProvider implements ApiAuthProvider { private token: string; constructor(token: string) { diff --git a/packages/host-service/src/providers/auth/JwtAuthProvider/index.ts b/packages/host-service/src/providers/auth/JwtAuthProvider/index.ts index d8fa9470d15..98fa128ff14 100644 --- a/packages/host-service/src/providers/auth/JwtAuthProvider/index.ts +++ b/packages/host-service/src/providers/auth/JwtAuthProvider/index.ts @@ -1 +1 @@ -export { JwtAuthProvider } from "./JwtAuthProvider"; +export { JwtApiAuthProvider } from "./JwtAuthProvider"; diff --git a/packages/host-service/src/providers/auth/index.ts b/packages/host-service/src/providers/auth/index.ts index 552004cd334..3f509288c27 100644 --- a/packages/host-service/src/providers/auth/index.ts +++ b/packages/host-service/src/providers/auth/index.ts @@ -1,3 +1,3 @@ -export { DeviceKeyAuthProvider } from "./DeviceKeyAuthProvider"; -export { JwtAuthProvider } from "./JwtAuthProvider"; -export type { AuthProvider } from "./types"; +export { DeviceKeyApiAuthProvider } from "./DeviceKeyAuthProvider"; +export { JwtApiAuthProvider } from "./JwtAuthProvider"; +export type { ApiAuthProvider } from "./types"; diff --git a/packages/host-service/src/providers/auth/types.ts b/packages/host-service/src/providers/auth/types.ts index 186a134e48f..6995ff58ef7 100644 --- a/packages/host-service/src/providers/auth/types.ts +++ b/packages/host-service/src/providers/auth/types.ts @@ -1,3 +1,3 @@ -export interface AuthProvider { +export interface ApiAuthProvider { getHeaders(): Promise>; } diff --git a/packages/host-service/src/providers/host-auth/PskHostAuthProvider/PskHostAuthProvider.ts b/packages/host-service/src/providers/host-auth/PskHostAuthProvider/PskHostAuthProvider.ts new file mode 100644 index 00000000000..9c723799c4a --- /dev/null +++ b/packages/host-service/src/providers/host-auth/PskHostAuthProvider/PskHostAuthProvider.ts @@ -0,0 +1,26 @@ +import { timingSafeEqual } from "node:crypto"; +import type { HostAuthProvider } from "../types"; + +export class PskHostAuthProvider implements HostAuthProvider { + private readonly secretBuffer: Buffer; + + constructor(secret: string) { + this.secretBuffer = Buffer.from(secret); + } + + validate(request: Request): boolean { + const header = request.headers.get("authorization"); + const token = header?.startsWith("Bearer ") ? header.slice(7) : null; + return !!token && this.safeEqual(token); + } + + validateToken(token: string): boolean { + return this.safeEqual(token); + } + + private safeEqual(input: string): boolean { + const inputBuffer = Buffer.from(input); + if (this.secretBuffer.length !== inputBuffer.length) return false; + return timingSafeEqual(this.secretBuffer, inputBuffer); + } +} diff --git a/packages/host-service/src/providers/host-auth/PskHostAuthProvider/index.ts b/packages/host-service/src/providers/host-auth/PskHostAuthProvider/index.ts new file mode 100644 index 00000000000..14a9f8a8d16 --- /dev/null +++ b/packages/host-service/src/providers/host-auth/PskHostAuthProvider/index.ts @@ -0,0 +1 @@ +export { PskHostAuthProvider } from "./PskHostAuthProvider"; diff --git a/packages/host-service/src/providers/host-auth/index.ts b/packages/host-service/src/providers/host-auth/index.ts new file mode 100644 index 00000000000..b3baf5b67de --- /dev/null +++ b/packages/host-service/src/providers/host-auth/index.ts @@ -0,0 +1,2 @@ +export { PskHostAuthProvider } from "./PskHostAuthProvider"; +export type { HostAuthProvider } from "./types"; diff --git a/packages/host-service/src/providers/host-auth/types.ts b/packages/host-service/src/providers/host-auth/types.ts new file mode 100644 index 00000000000..23fc7fa4ecc --- /dev/null +++ b/packages/host-service/src/providers/host-auth/types.ts @@ -0,0 +1,6 @@ +export interface HostAuthProvider { + /** Validate an inbound HTTP request. Return true if authorized. */ + validate(request: Request): Promise | boolean; + /** Validate a raw token string (e.g. from a WebSocket ?token= query param). */ + validateToken(token: string): Promise | boolean; +} diff --git a/packages/host-service/src/serve.ts b/packages/host-service/src/serve.ts index bf44a8114f4..4ad90b32032 100644 --- a/packages/host-service/src/serve.ts +++ b/packages/host-service/src/serve.ts @@ -1,11 +1,17 @@ import { serve } from "@hono/node-server"; import { createApp } from "./app"; +import { env } from "./env"; +import { PskHostAuthProvider } from "./providers/host-auth"; -const dbPath = process.env.HOST_DB_PATH?.trim() || undefined; -const { app, injectWebSocket } = createApp({ dbPath }); -const port = Number(process.env.PORT) || 4879; +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 }, (info) => { +const server = serve({ fetch: app.fetch, port: env.PORT }, (info) => { console.log(`[host-service] listening on http://localhost:${info.port}`); + console.log(`[host-service] secret: ${env.HOST_SERVICE_SECRET}`); }); injectWebSocket(server); diff --git a/packages/host-service/src/trpc/index.ts b/packages/host-service/src/trpc/index.ts index b50b33042c8..0511e9b9545 100644 --- a/packages/host-service/src/trpc/index.ts +++ b/packages/host-service/src/trpc/index.ts @@ -1,4 +1,4 @@ -import { initTRPC } from "@trpc/server"; +import { TRPCError, initTRPC } from "@trpc/server"; import superjson from "superjson"; import type { HostServiceContext } from "../types"; @@ -9,4 +9,14 @@ const t = initTRPC export const router = t.router; export const publicProcedure = t.procedure; +export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { + if (!ctx.isAuthenticated) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid or missing authentication token.", + }); + } + return next({ ctx }); +}); + export type { AppRouter } from "./router"; diff --git a/packages/host-service/src/trpc/router/chat/chat.ts b/packages/host-service/src/trpc/router/chat/chat.ts index 4802537b805..d0487318c82 100644 --- a/packages/host-service/src/trpc/router/chat/chat.ts +++ b/packages/host-service/src/trpc/router/chat/chat.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { publicProcedure, router } from "../../index"; +import { protectedProcedure, router } from "../../index"; const thinkingLevelSchema = z.enum(["off", "low", "medium", "high", "xhigh"]); @@ -22,17 +22,17 @@ const sendMessagePayloadSchema = z.object({ }); export const chatRouter = router({ - getDisplayState: publicProcedure + getDisplayState: protectedProcedure .input(sessionInput) .query(({ ctx, input }) => { return ctx.runtime.chat.getDisplayState(input); }), - listMessages: publicProcedure.input(sessionInput).query(({ ctx, input }) => { + listMessages: protectedProcedure.input(sessionInput).query(({ ctx, input }) => { return ctx.runtime.chat.listMessages(input); }), - sendMessage: publicProcedure + sendMessage: protectedProcedure .input( sessionInput.extend({ payload: sendMessagePayloadSchema, @@ -48,7 +48,7 @@ export const chatRouter = router({ return ctx.runtime.chat.sendMessage(input); }), - restartFromMessage: publicProcedure + restartFromMessage: protectedProcedure .input( sessionInput.extend({ messageId: z.string().min(1), @@ -65,11 +65,11 @@ export const chatRouter = router({ return ctx.runtime.chat.restartFromMessage(input); }), - stop: publicProcedure.input(sessionInput).mutation(({ ctx, input }) => { + stop: protectedProcedure.input(sessionInput).mutation(({ ctx, input }) => { return ctx.runtime.chat.stop(input); }), - respondToApproval: publicProcedure + respondToApproval: protectedProcedure .input( sessionInput.extend({ payload: z.object({ @@ -81,7 +81,7 @@ export const chatRouter = router({ return ctx.runtime.chat.respondToApproval(input); }), - respondToQuestion: publicProcedure + respondToQuestion: protectedProcedure .input( sessionInput.extend({ payload: z.object({ @@ -94,7 +94,7 @@ export const chatRouter = router({ return ctx.runtime.chat.respondToQuestion(input); }), - respondToPlan: publicProcedure + respondToPlan: protectedProcedure .input( sessionInput.extend({ payload: z.object({ @@ -110,13 +110,13 @@ export const chatRouter = router({ return ctx.runtime.chat.respondToPlan(input); }), - getSlashCommands: publicProcedure + getSlashCommands: protectedProcedure .input(sessionInput) .query(({ ctx, input }) => { return ctx.runtime.chat.getSlashCommands(input); }), - resolveSlashCommand: publicProcedure + resolveSlashCommand: protectedProcedure .input( sessionInput.extend({ text: z.string(), @@ -126,7 +126,7 @@ export const chatRouter = router({ return ctx.runtime.chat.resolveSlashCommand(input); }), - previewSlashCommand: publicProcedure + previewSlashCommand: protectedProcedure .input( sessionInput.extend({ text: z.string(), @@ -136,7 +136,7 @@ export const chatRouter = router({ return ctx.runtime.chat.previewSlashCommand(input); }), - getMcpOverview: publicProcedure + getMcpOverview: protectedProcedure .input(sessionInput) .query(({ ctx, input }) => { return ctx.runtime.chat.getMcpOverview(input); diff --git a/packages/host-service/src/trpc/router/cloud/cloud.ts b/packages/host-service/src/trpc/router/cloud/cloud.ts index 255f58b1f97..6f1fefb2f91 100644 --- a/packages/host-service/src/trpc/router/cloud/cloud.ts +++ b/packages/host-service/src/trpc/router/cloud/cloud.ts @@ -1,9 +1,9 @@ import { TRPCError } from "@trpc/server"; -import { publicProcedure, router } from "../../index"; +import { protectedProcedure, router } from "../../index"; // TODO: Remove this test router in favor of product-led endpoints export const cloudRouter = router({ - whoami: publicProcedure.query(async ({ ctx }) => { + whoami: protectedProcedure.query(async ({ ctx }) => { if (!ctx.api) { throw new TRPCError({ code: "PRECONDITION_FAILED", diff --git a/packages/host-service/src/trpc/router/filesystem/filesystem.ts b/packages/host-service/src/trpc/router/filesystem/filesystem.ts index ffef91a349a..04e42246c1d 100644 --- a/packages/host-service/src/trpc/router/filesystem/filesystem.ts +++ b/packages/host-service/src/trpc/router/filesystem/filesystem.ts @@ -1,7 +1,7 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import type { HostServiceContext } from "../../../types"; -import { publicProcedure, router } from "../../index"; +import { protectedProcedure, router } from "../../index"; function getFilesystemService(ctx: HostServiceContext, workspaceId: string) { try { @@ -29,7 +29,7 @@ const writeFileContentSchema = z.union([ ]); export const filesystemRouter = router({ - listDirectory: publicProcedure + listDirectory: protectedProcedure .input( z.object({ workspaceId: z.string(), @@ -42,7 +42,7 @@ export const filesystemRouter = router({ return await service.listDirectory(serviceInput); }), - readFile: publicProcedure + readFile: protectedProcedure .input( z.object({ workspaceId: z.string(), @@ -67,7 +67,7 @@ export const filesystemRouter = router({ return result; }), - getMetadata: publicProcedure + getMetadata: protectedProcedure .input( z.object({ workspaceId: z.string(), @@ -80,7 +80,7 @@ export const filesystemRouter = router({ return await service.getMetadata(serviceInput); }), - writeFile: publicProcedure + writeFile: protectedProcedure .input( z.object({ workspaceId: z.string(), @@ -114,7 +114,7 @@ export const filesystemRouter = router({ }); }), - createDirectory: publicProcedure + createDirectory: protectedProcedure .input( z.object({ workspaceId: z.string(), @@ -128,7 +128,7 @@ export const filesystemRouter = router({ return await service.createDirectory(serviceInput); }), - deletePath: publicProcedure + deletePath: protectedProcedure .input( z.object({ workspaceId: z.string(), @@ -142,7 +142,7 @@ export const filesystemRouter = router({ return await service.deletePath(serviceInput); }), - movePath: publicProcedure + movePath: protectedProcedure .input( z.object({ workspaceId: z.string(), @@ -156,7 +156,7 @@ export const filesystemRouter = router({ return await service.movePath(serviceInput); }), - copyPath: publicProcedure + copyPath: protectedProcedure .input( z.object({ workspaceId: z.string(), @@ -170,7 +170,7 @@ export const filesystemRouter = router({ return await service.copyPath(serviceInput); }), - searchFiles: publicProcedure + searchFiles: protectedProcedure .input( z.object({ workspaceId: z.string(), @@ -195,7 +195,7 @@ export const filesystemRouter = router({ }); }), - searchContent: publicProcedure + searchContent: protectedProcedure .input( z.object({ workspaceId: z.string(), diff --git a/packages/host-service/src/trpc/router/git/git.ts b/packages/host-service/src/trpc/router/git/git.ts index 1a23a701c03..855c17d37e6 100644 --- a/packages/host-service/src/trpc/router/git/git.ts +++ b/packages/host-service/src/trpc/router/git/git.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -import { publicProcedure, router } from "../../index"; +import { protectedProcedure, router } from "../../index"; // TODO: Remove this test router in favor of product-led endpoints (i.e. workspace.create()) export const gitRouter = router({ - status: publicProcedure + status: protectedProcedure .input(z.object({ path: z.string() })) .query(async ({ ctx, input }) => { const git = await ctx.git(input.path); diff --git a/packages/host-service/src/trpc/router/github/github.ts b/packages/host-service/src/trpc/router/github/github.ts index e2760a7cd25..9ea6722e195 100644 --- a/packages/host-service/src/trpc/router/github/github.ts +++ b/packages/host-service/src/trpc/router/github/github.ts @@ -1,8 +1,8 @@ import { z } from "zod"; -import { publicProcedure, router } from "../../index"; +import { protectedProcedure, router } from "../../index"; export const githubRouter = router({ - getPRStatus: publicProcedure + getPRStatus: protectedProcedure .input( z.object({ owner: z.string(), @@ -21,7 +21,7 @@ export const githubRouter = router({ return data[0] ?? null; }), - getPR: publicProcedure + getPR: protectedProcedure .input( z.object({ owner: z.string(), @@ -39,7 +39,7 @@ export const githubRouter = router({ return data; }), - listPRs: publicProcedure + listPRs: protectedProcedure .input( z.object({ owner: z.string(), @@ -67,7 +67,7 @@ export const githubRouter = router({ return data; }), - getRepo: publicProcedure + getRepo: protectedProcedure .input( z.object({ owner: z.string(), @@ -83,7 +83,7 @@ export const githubRouter = router({ return data; }), - listDeployments: publicProcedure + listDeployments: protectedProcedure .input( z.object({ owner: z.string(), @@ -105,7 +105,7 @@ export const githubRouter = router({ return data; }), - listDeploymentStatuses: publicProcedure + listDeploymentStatuses: protectedProcedure .input( z.object({ owner: z.string(), @@ -125,13 +125,13 @@ export const githubRouter = router({ return data; }), - getUser: publicProcedure.query(async ({ ctx }) => { + getUser: protectedProcedure.query(async ({ ctx }) => { const octokit = await ctx.github(); const { data } = await octokit.users.getAuthenticated(); return data; }), - mergePR: publicProcedure + mergePR: protectedProcedure .input( z.object({ owner: z.string(), diff --git a/packages/host-service/src/trpc/router/project/project.ts b/packages/host-service/src/trpc/router/project/project.ts index 2894d0ef007..f327a4df3af 100644 --- a/packages/host-service/src/trpc/router/project/project.ts +++ b/packages/host-service/src/trpc/router/project/project.ts @@ -2,11 +2,11 @@ import { rmSync } from "node:fs"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; -import { publicProcedure, router } from "../../index"; +import { protectedProcedure, router } from "../../index"; export const projectRouter = router({ // TODO: remove - removeFromDevice: publicProcedure + removeFromDevice: protectedProcedure .input(z.object({ projectId: z.string() })) .mutation(async ({ ctx, input }) => { const localProject = ctx.db.query.projects diff --git a/packages/host-service/src/trpc/router/pull-requests/pull-requests.ts b/packages/host-service/src/trpc/router/pull-requests/pull-requests.ts index aebcf8eb34c..d36806c668a 100644 --- a/packages/host-service/src/trpc/router/pull-requests/pull-requests.ts +++ b/packages/host-service/src/trpc/router/pull-requests/pull-requests.ts @@ -1,8 +1,8 @@ import { z } from "zod"; -import { publicProcedure, router } from "../../index"; +import { protectedProcedure, router } from "../../index"; export const pullRequestsRouter = router({ - getByWorkspaces: publicProcedure + getByWorkspaces: protectedProcedure .input( z.object({ workspaceIds: z.array(z.string()), @@ -15,7 +15,7 @@ export const pullRequestsRouter = router({ ); return { workspaces }; }), - refreshByWorkspaces: publicProcedure + refreshByWorkspaces: protectedProcedure .input( z.object({ workspaceIds: z.array(z.string()), diff --git a/packages/host-service/src/trpc/router/workspace/workspace.ts b/packages/host-service/src/trpc/router/workspace/workspace.ts index 08ff539c8c7..85b51fca494 100644 --- a/packages/host-service/src/trpc/router/workspace/workspace.ts +++ b/packages/host-service/src/trpc/router/workspace/workspace.ts @@ -5,10 +5,10 @@ import { eq } from "drizzle-orm"; import simpleGit from "simple-git"; import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; -import { publicProcedure, router } from "../../index"; +import { protectedProcedure, router } from "../../index"; export const workspaceRouter = router({ - get: publicProcedure + get: protectedProcedure .input(z.object({ id: z.string() })) .query(({ ctx, input }) => { const localWorkspace = ctx.db.query.workspaces @@ -25,7 +25,7 @@ export const workspaceRouter = router({ return localWorkspace; }), - create: publicProcedure + create: protectedProcedure .input( z.object({ projectId: z.string(), @@ -139,7 +139,7 @@ export const workspaceRouter = router({ return cloudRow; }), - gitStatus: publicProcedure + gitStatus: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const localWorkspace = ctx.db.query.workspaces @@ -168,7 +168,7 @@ export const workspaceRouter = router({ }; }), - delete: publicProcedure + delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { if (!ctx.api) { diff --git a/packages/host-service/src/types.ts b/packages/host-service/src/types.ts index 98e914bc40f..b988590a598 100644 --- a/packages/host-service/src/types.ts +++ b/packages/host-service/src/types.ts @@ -23,4 +23,5 @@ export interface HostServiceContext { runtime: HostServiceRuntime; deviceClientId: string | null; deviceName: string | null; + isAuthenticated: boolean; } diff --git a/packages/scripts/package.json b/packages/scripts/package.json deleted file mode 100644 index ba77532123a..00000000000 --- a/packages/scripts/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@superset/scripts", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "clean": "git clean -xdf .cache .turbo dist node_modules" - } -} diff --git a/packages/scripts/tsconfig.json b/packages/scripts/tsconfig.json deleted file mode 100644 index 45ec79bcf2f..00000000000 --- a/packages/scripts/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "@superset/typescript/base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "types": ["node"] - }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"] -} diff --git a/packages/workspace-client/src/index.ts b/packages/workspace-client/src/index.ts index d93b0ffc56a..dc68deaf589 100644 --- a/packages/workspace-client/src/index.ts +++ b/packages/workspace-client/src/index.ts @@ -14,6 +14,7 @@ export { useWorkspaceFsEvents } from "./hooks/useWorkspaceFsEvents"; export { useWorkspaceClient, useWorkspaceHostUrl, + useWorkspaceWsUrl, type WorkspaceClientContextValue, WorkspaceClientProvider, type WorkspaceFsSubscriptionInput, diff --git a/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx b/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx index be3f243c05b..fba62d27537 100644 --- a/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx +++ b/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx @@ -22,16 +22,28 @@ export interface WorkspaceClientContextValue { subscribeToWorkspaceFsEvents: ( input: WorkspaceFsSubscriptionInput, ) => () => void; + /** Get a WS auth token (for ?token= on upgrade URLs). */ + getWsToken: () => string | null; } interface WorkspaceClientProviderProps { cacheKey: string; hostUrl: string; children: ReactNode; + /** Lazy headers callback — evaluated on each tRPC request. */ + headers?: () => Record; + /** Token for WebSocket upgrade requests (appended as ?token=). */ + wsToken?: () => string | null; } -interface WorkspaceClients extends WorkspaceClientContextValue { +interface WorkspaceClients { + hostUrl: string; + queryClient: QueryClient; trpcClient: ReturnType; + subscribeToWorkspaceFsEvents: ( + input: WorkspaceFsSubscriptionInput, + ) => () => void; + getWsToken: () => string | null; } const workspaceClientsCache = new Map(); @@ -41,9 +53,14 @@ const WorkspaceClientContext = function toWorkspaceFilesystemEventsUrl( hostUrl: string, workspaceId: string, + getWsToken?: () => string | null, ): string { const url = new URL(buildWorkspaceFilesystemEventsPath(workspaceId), hostUrl); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + const token = getWsToken?.(); + if (token) { + url.searchParams.set("token", token); + } return url.toString(); } @@ -55,9 +72,10 @@ function toSubscriptionError(message: string, event?: CloseEvent): Error { function createWorkspaceFsSubscription( hostUrl: string, input: WorkspaceFsSubscriptionInput, + getWsToken?: () => string | null, ): () => void { const socket = new WebSocket( - toWorkspaceFilesystemEventsUrl(hostUrl, input.workspaceId), + toWorkspaceFilesystemEventsUrl(hostUrl, input.workspaceId, getWsToken), ); let disposed = false; let opened = false; @@ -124,6 +142,8 @@ function createWorkspaceFsSubscription( function getWorkspaceClients( cacheKey: string, hostUrl: string, + headers?: () => Record, + wsToken?: () => string | null, ): WorkspaceClients { const clientKey = `${cacheKey}:${hostUrl}`; const cached = workspaceClientsCache.get(clientKey); @@ -147,16 +167,19 @@ function getWorkspaceClients( httpBatchLink({ url: `${hostUrl}/trpc`, transformer: superjson, + headers: headers ?? (() => ({})), }), ], }); + const getWsToken = wsToken ?? (() => null); const clients: WorkspaceClients = { hostUrl, queryClient, trpcClient, + getWsToken, subscribeToWorkspaceFsEvents(input) { - return createWorkspaceFsSubscription(hostUrl, input); + return createWorkspaceFsSubscription(hostUrl, input, getWsToken); }, }; workspaceClientsCache.set(clientKey, clients); @@ -166,13 +189,16 @@ function getWorkspaceClients( export function WorkspaceClientProvider({ cacheKey, hostUrl, + headers, + wsToken, children, }: WorkspaceClientProviderProps) { - const clients = getWorkspaceClients(cacheKey, hostUrl); + const clients = getWorkspaceClients(cacheKey, hostUrl, headers, wsToken); const contextValue: WorkspaceClientContextValue = { hostUrl: clients.hostUrl, queryClient: clients.queryClient, subscribeToWorkspaceFsEvents: clients.subscribeToWorkspaceFsEvents, + getWsToken: clients.getWsToken, }; return ( @@ -203,3 +229,22 @@ export function useWorkspaceClient(): WorkspaceClientContextValue { export function useWorkspaceHostUrl(): string { return useWorkspaceClient().hostUrl; } + +export function useWorkspaceWsUrl( + path: string, + params?: Record, +): string { + const { hostUrl, getWsToken } = useWorkspaceClient(); + const url = new URL(path, hostUrl); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + if (params) { + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + } + const token = getWsToken(); + if (token) { + url.searchParams.set("token", token); + } + return url.toString(); +} diff --git a/packages/workspace-client/src/providers/WorkspaceClientProvider/index.ts b/packages/workspace-client/src/providers/WorkspaceClientProvider/index.ts index 477e4aee9fd..71e8bd48df8 100644 --- a/packages/workspace-client/src/providers/WorkspaceClientProvider/index.ts +++ b/packages/workspace-client/src/providers/WorkspaceClientProvider/index.ts @@ -1,6 +1,7 @@ export { useWorkspaceClient, useWorkspaceHostUrl, + useWorkspaceWsUrl, type WorkspaceClientContextValue, WorkspaceClientProvider, type WorkspaceFsSubscriptionInput, From b6c744dd78e3998f98a67afcfa1027a0a7861af4 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 26 Mar 2026 20:34:44 -0400 Subject: [PATCH 2/6] fix: biome lint/format fixes --- apps/desktop/src/renderer/lib/host-service-auth.ts | 4 +--- .../providers/HostServiceProvider/HostServiceProvider.tsx | 2 +- packages/host-service/src/app.ts | 2 +- packages/host-service/src/index.ts | 4 ++-- packages/host-service/src/trpc/index.ts | 2 +- packages/host-service/src/trpc/router/chat/chat.ts | 8 +++++--- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/renderer/lib/host-service-auth.ts b/apps/desktop/src/renderer/lib/host-service-auth.ts index 476f8820581..f3642bb2393 100644 --- a/apps/desktop/src/renderer/lib/host-service-auth.ts +++ b/apps/desktop/src/renderer/lib/host-service-auth.ts @@ -18,9 +18,7 @@ export function removeHostServiceSecret(hostUrl: string): void { secrets.delete(hostUrl); } -export function getHostServiceHeaders( - hostUrl: string, -): Record { +export function getHostServiceHeaders(hostUrl: string): Record { const secret = secrets.get(hostUrl); return secret ? { Authorization: `Bearer ${secret}` } : {}; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx index d65e5bdc0f0..e368a46a88c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx @@ -9,11 +9,11 @@ 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, } from "renderer/lib/host-service-client"; -import { setHostServiceSecret } from "renderer/lib/host-service-auth"; import { MOCK_ORG_ID } from "shared/constants"; import { useCollections } from "../CollectionsProvider"; diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index c1923978f4e..a853d736ac0 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -10,8 +10,8 @@ import { createApiClient } from "./api"; import { createDb } from "./db"; import { registerWorkspaceFilesystemEventsRoute } from "./filesystem"; import type { ApiAuthProvider } from "./providers/auth"; -import type { HostAuthProvider } from "./providers/host-auth"; import { LocalGitCredentialProvider } from "./providers/git"; +import type { HostAuthProvider } from "./providers/host-auth"; import { LocalModelProvider, type ModelProviderRuntimeResolver, diff --git a/packages/host-service/src/index.ts b/packages/host-service/src/index.ts index feb92238c0d..8414141622b 100644 --- a/packages/host-service/src/index.ts +++ b/packages/host-service/src/index.ts @@ -7,12 +7,12 @@ export { } from "./filesystem"; export type { ApiAuthProvider } from "./providers/auth"; export { DeviceKeyApiAuthProvider, JwtApiAuthProvider } from "./providers/auth"; -export type { HostAuthProvider } from "./providers/host-auth"; -export { PskHostAuthProvider } from "./providers/host-auth"; export { CloudGitCredentialProvider, LocalGitCredentialProvider, } from "./providers/git"; +export type { HostAuthProvider } from "./providers/host-auth"; +export { PskHostAuthProvider } from "./providers/host-auth"; export type { ModelProviderRuntimeResolver } from "./providers/model-providers"; export { CloudModelProvider, diff --git a/packages/host-service/src/trpc/index.ts b/packages/host-service/src/trpc/index.ts index 0511e9b9545..b4324e8d292 100644 --- a/packages/host-service/src/trpc/index.ts +++ b/packages/host-service/src/trpc/index.ts @@ -1,4 +1,4 @@ -import { TRPCError, initTRPC } from "@trpc/server"; +import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import type { HostServiceContext } from "../types"; diff --git a/packages/host-service/src/trpc/router/chat/chat.ts b/packages/host-service/src/trpc/router/chat/chat.ts index d0487318c82..e668413a647 100644 --- a/packages/host-service/src/trpc/router/chat/chat.ts +++ b/packages/host-service/src/trpc/router/chat/chat.ts @@ -28,9 +28,11 @@ export const chatRouter = router({ return ctx.runtime.chat.getDisplayState(input); }), - listMessages: protectedProcedure.input(sessionInput).query(({ ctx, input }) => { - return ctx.runtime.chat.listMessages(input); - }), + listMessages: protectedProcedure + .input(sessionInput) + .query(({ ctx, input }) => { + return ctx.runtime.chat.listMessages(input); + }), sendMessage: protectedProcedure .input( From 06737f6b9b3d80eea1b70fd333d4022ad94d969b Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 26 Mar 2026 20:41:28 -0400 Subject: [PATCH 3/6] chore: remove unnecessary comments, clean up ws auth middleware --- .../src/renderer/lib/host-service-auth.ts | 14 +++----------- packages/host-service/src/app.ts | 16 ++++++---------- .../src/providers/host-auth/types.ts | 2 -- .../WorkspaceClientProvider.tsx | 3 --- 4 files changed, 9 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/renderer/lib/host-service-auth.ts b/apps/desktop/src/renderer/lib/host-service-auth.ts index f3642bb2393..c37b4df25d7 100644 --- a/apps/desktop/src/renderer/lib/host-service-auth.ts +++ b/apps/desktop/src/renderer/lib/host-service-auth.ts @@ -1,13 +1,3 @@ -/** - * Host-service auth registry. - * - * Module-level secret store keyed by hostUrl. HostServiceProvider writes - * secrets here; all host-service clients (tRPC + WebSocket) read lazily - * via callback headers — mirroring the api-trpc-client.ts getAuthToken() - * pattern. This is the single auth configuration point for host-service - * connections in the renderer. - */ - const secrets = new Map(); export function setHostServiceSecret(hostUrl: string, secret: string): void { @@ -18,7 +8,9 @@ export function removeHostServiceSecret(hostUrl: string): void { secrets.delete(hostUrl); } -export function getHostServiceHeaders(hostUrl: string): Record { +export function getHostServiceHeaders( + hostUrl: string, +): Record { const secret = secrets.get(hostUrl); return secret ? { Authorization: `Bearer ${secret}` } : {}; } diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index a853d736ac0..89da8f230f1 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -91,18 +91,14 @@ export function createApp(options?: CreateAppOptions): CreateAppResult { }), ); - // Auth guard for WebSocket routes (must be registered before route handlers) if (options?.hostAuth) { - const hostAuth = options.hostAuth; + const { hostAuth } = options; const wsAuth: MiddlewareHandler = async (c, next) => { - const headerValid = await hostAuth.validate(c.req.raw); - const queryToken = c.req.query("token"); - const queryValid = queryToken - ? await hostAuth.validateToken(queryToken) - : false; - if (!headerValid && !queryValid) { - return c.json({ error: "Unauthorized" }, 401); - } + const token = c.req.query("token"); + const authorized = + (await hostAuth.validate(c.req.raw)) || + (token && (await hostAuth.validateToken(token))); + if (!authorized) return c.json({ error: "Unauthorized" }, 401); return next(); }; app.use("/terminal/*", wsAuth); diff --git a/packages/host-service/src/providers/host-auth/types.ts b/packages/host-service/src/providers/host-auth/types.ts index 23fc7fa4ecc..e7bc674c92b 100644 --- a/packages/host-service/src/providers/host-auth/types.ts +++ b/packages/host-service/src/providers/host-auth/types.ts @@ -1,6 +1,4 @@ export interface HostAuthProvider { - /** Validate an inbound HTTP request. Return true if authorized. */ validate(request: Request): Promise | boolean; - /** Validate a raw token string (e.g. from a WebSocket ?token= query param). */ validateToken(token: string): Promise | boolean; } diff --git a/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx b/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx index fba62d27537..5462da85cb2 100644 --- a/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx +++ b/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx @@ -22,7 +22,6 @@ export interface WorkspaceClientContextValue { subscribeToWorkspaceFsEvents: ( input: WorkspaceFsSubscriptionInput, ) => () => void; - /** Get a WS auth token (for ?token= on upgrade URLs). */ getWsToken: () => string | null; } @@ -30,9 +29,7 @@ interface WorkspaceClientProviderProps { cacheKey: string; hostUrl: string; children: ReactNode; - /** Lazy headers callback — evaluated on each tRPC request. */ headers?: () => Record; - /** Token for WebSocket upgrade requests (appended as ?token=). */ wsToken?: () => string | null; } From 47419105fcfe6b8a84fd79606c80f3e09ab8c78f Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 26 Mar 2026 20:44:36 -0400 Subject: [PATCH 4/6] fix: biome format fix --- apps/desktop/src/renderer/lib/host-service-auth.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/lib/host-service-auth.ts b/apps/desktop/src/renderer/lib/host-service-auth.ts index c37b4df25d7..e0b662150ee 100644 --- a/apps/desktop/src/renderer/lib/host-service-auth.ts +++ b/apps/desktop/src/renderer/lib/host-service-auth.ts @@ -8,9 +8,7 @@ export function removeHostServiceSecret(hostUrl: string): void { secrets.delete(hostUrl); } -export function getHostServiceHeaders( - hostUrl: string, -): Record { +export function getHostServiceHeaders(hostUrl: string): Record { const secret = secrets.get(hostUrl); return secret ? { Authorization: `Bearer ${secret}` } : {}; } From 8eef468a4521522cda73a74f3cfce7d81a71868a Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 26 Mar 2026 21:00:37 -0400 Subject: [PATCH 5/6] fix: fix flaky external worktree test, remove broken workspaceRun test --- .../external-worktree-import.test.ts | 5 +--- .../Terminal/hooks/workspaceRun.test.ts | 30 +------------------ 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/external-worktree-import.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/external-worktree-import.test.ts index 8ddf2edae7d..9cba6c0dd98 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/external-worktree-import.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/external-worktree-import.test.ts @@ -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 @@ -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 diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/workspaceRun.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/workspaceRun.test.ts index c992510f449..66e6b9dd5ce 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/workspaceRun.test.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/workspaceRun.test.ts @@ -46,9 +46,7 @@ mock.module("renderer/stores/tabs/store", () => ({ }, })); -const { recoverWorkspaceRunPane, setPaneWorkspaceRunState } = await import( - "./workspaceRun" -); +const { recoverWorkspaceRunPane } = await import("./workspaceRun"); describe("recoverWorkspaceRunPane", () => { beforeEach(() => { @@ -290,30 +288,4 @@ describe("recoverWorkspaceRunPane", () => { command: "bun run dev", }); }); - - it("preserves the stored run command when updating workspace-run state", () => { - storeState.panes["pane-3"] = { - workspaceRun: { - workspaceId: "ws-3", - state: "running", - command: "bun run dev", - }, - }; - - const updatedWorkspaceRun = setPaneWorkspaceRunState( - "pane-3", - "stopped-by-exit", - ); - - expect(updatedWorkspaceRun).toEqual({ - workspaceId: "ws-3", - state: "stopped-by-exit", - command: "bun run dev", - }); - expect(storeState.setPaneWorkspaceRun).toHaveBeenCalledWith("pane-3", { - workspaceId: "ws-3", - state: "stopped-by-exit", - command: "bun run dev", - }); - }); }); From 913c50a76adfef3d85ed1a971e0df3b62abbb8fe Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 26 Mar 2026 21:15:14 -0400 Subject: [PATCH 6/6] Clean enougH --- packages/host-service/src/serve.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/host-service/src/serve.ts b/packages/host-service/src/serve.ts index 4ad90b32032..7f4c369e782 100644 --- a/packages/host-service/src/serve.ts +++ b/packages/host-service/src/serve.ts @@ -12,6 +12,5 @@ const { app, injectWebSocket } = createApp({ const server = serve({ fetch: app.fetch, port: env.PORT }, (info) => { console.log(`[host-service] listening on http://localhost:${info.port}`); - console.log(`[host-service] secret: ${env.HOST_SERVICE_SECRET}`); }); injectWebSocket(server);